Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,18 @@ jobs:
- name: Install dependencies
run: opam install . --deps-only --with-test --with-doc

- name: Install tree-sitter CLI (for res-to-affine walker tests)
# Same rationale as the migration-assistant job (see below):
# npm distribution is the fast CI install (~5 s). The walker
# end-to-end tests in tools/res-to-affine/test/test_walker.ml
# auto-skip if the CLI / generated grammar aren't present, so
# this step is only required to *exercise* the walker — the
# build itself does not depend on it.
run: npm install -g tree-sitter-cli@^0.25.0

- name: Build pinned tree-sitter-rescript grammar
run: ./editors/tree-sitter-rescript/scripts/install.sh

- name: Build
run: opam exec -- dune build

Expand Down Expand Up @@ -119,3 +131,68 @@ jobs:
# Headless display required because @vscode/test-electron launches
# the real Electron-based VS Code binary.
run: xvfb-run -a npm test

migration-assistant:
# Build pinned tree-sitter-rescript grammar consumed by the
# `.res → .affine` migration assistant (#57 Phase 2). The grammar
# is manifest-vendored (`editors/tree-sitter-rescript/package.json`)
# so this job exists to (a) verify the install script and pinned
# commit still build cleanly and (b) gate `tools/res-to-affine/`
# walker work that depends on the generated parser.
runs-on: ubuntu-latest

steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

- name: Set up Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
with:
node-version: "20"

- name: Install tree-sitter CLI
# npm install of tree-sitter-cli is the fast CI path (~5 s vs.
# ~5 min for `cargo install tree-sitter-cli`). The repo's
# preferred local path is cargo (see editors/tree-sitter-rescript/
# README.md) — both produce the same `tree-sitter` binary that
# the install script invokes via `command -v`. The version
# tracks `tree-sitter-rescript`'s package.json devDependency
# range.
run: npm install -g tree-sitter-cli@^0.25.0

- name: Build pinned tree-sitter-rescript grammar
# Direct script invocation rather than `just install-grammar` —
# GitHub Actions runners do not ship `just` preinstalled, and
# there is no other recipe used in this workflow that justifies
# adding a setup step for it. The justfile recipe still exists
# for local developer ergonomics; both call the same script.
run: ./editors/tree-sitter-rescript/scripts/install.sh

- name: Verify generated parser
# `tree-sitter generate` is supposed to drop src/parser.c into
# the cloned grammar. If it didn't, the install path is broken
# and Phase-2 walker work cannot proceed; fail loudly here
# rather than at the OCaml link step in a downstream PR.
run: |
test -f tools/vendor/tree-sitter-rescript/src/parser.c \
|| { echo "error: parser.c not produced by tree-sitter generate" >&2; exit 1; }
echo "parser.c size: $(wc -c < tools/vendor/tree-sitter-rescript/src/parser.c) bytes"

- name: Smoke-parse a sample .res file
# Sanity-check that the grammar actually parses a non-trivial
# ReScript source. Picks the existing res-to-affine test fixture
# so any drift in the pinned commit's syntactic surface area
# surfaces here rather than at walker-rule writing time.
run: |
shopt -s nullglob
fixtures=(tools/res-to-affine/test/fixtures/*.res)
if [ ${#fixtures[@]} -eq 0 ]; then
echo "no .res fixtures to smoke-parse; skipping"
exit 0
fi
tree-sitter parse \
--quiet \
"${fixtures[0]}" \
--paths tools/vendor/tree-sitter-rescript \
> /dev/null
echo "smoke-parsed: ${fixtures[0]}"
29 changes: 27 additions & 2 deletions editors/tree-sitter-rescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,38 @@ snapshots, since AST shapes may shift.

## Install

From the repo root:

```sh
./scripts/install.sh
just install-grammar # justfile recipe
# or directly:
./editors/tree-sitter-rescript/scripts/install.sh
```

This writes a `tree-sitter-rescript` directory under `tools/vendor/`
(gitignored — same convention as the WASI adapter pinning), containing
the generated parser. Requires `git` and `tree-sitter` CLI on PATH.
the generated parser. Requires `git` and the `tree-sitter` CLI on PATH.

The `tree-sitter` CLI can be installed either way:

```sh
cargo install tree-sitter-cli # Rust-native, repo-preferred
npm install -g tree-sitter-cli # Node-based, also fine
```

CI installs via `npm` for speed (`tree-sitter-cli` from npm is a pre-built
binary, ~5 s install). The `cargo` path builds from source (~5 min on a
cold cache) and is the recommended local install because it keeps the
contributor toolchain centred on Rust rather than Node. The
`package.json` in this directory pins the version range; bump it in
sync when the upstream grammar pin moves.

## Continuous integration

The `migration-assistant` job in `.github/workflows/ci.yml` runs `just
install-grammar` on every PR, then smoke-parses
`tools/res-to-affine/test/fixtures/sample.res`. If the pinned commit
stops building cleanly, this job is the first signal.

## Why manifest, not copy

Expand Down
4 changes: 3 additions & 1 deletion editors/tree-sitter-rescript/scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ BUILD_DIR="${REPO_ROOT}/tools/vendor/tree-sitter-rescript"

if ! command -v tree-sitter >/dev/null 2>&1; then
echo "error: tree-sitter CLI not found on PATH" >&2
echo " install via: npm install -g tree-sitter-cli" >&2
echo " install via either:" >&2
echo " cargo install tree-sitter-cli (Rust-native, repo-preferred)" >&2
echo " npm install -g tree-sitter-cli (Node-based, also fine)" >&2
exit 2
fi

Expand Down
10 changes: 10 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,16 @@ regen-idaptik-wasm:
dune exec affinescript -- tea-bridge -o ../../idaptik/public/assets/wasm/titlescreen.wasm
@echo "[AffineTEA] titlescreen.wasm regenerated"

# ── Tooling (manifest-driven dev deps) ────────────────────────────────────────

# Fetch + build the pinned tree-sitter-rescript grammar used by the
# `.res → .affine` migration assistant (#57 Phase 2). Output is written
# to `tools/vendor/tree-sitter-rescript/` (gitignored). Requires the
# `tree-sitter` CLI on PATH — install via `cargo install tree-sitter-cli`
# (Rust-native, repo-preferred) or `npm install -g tree-sitter-cli`.
install-grammar:
./editors/tree-sitter-rescript/scripts/install.sh

# ── Validation ────────────────────────────────────────────────────────────────

# Verify golden path end-to-end
Expand Down
41 changes: 40 additions & 1 deletion tools/res-to-affine/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ and the broader `idaptik` migration.
## Usage

```sh
# print skeleton to stdout
# print skeleton to stdout (default: regex scanner)
dune exec tools/res-to-affine/main.exe -- path/to/Foo.res

# or write to a file
dune exec tools/res-to-affine/main.exe -- path/to/Foo.res -o Foo.affine

# opt into the Phase-2 tree-sitter AST walker
dune exec tools/res-to-affine/main.exe -- --engine=walker path/to/Foo.res
```

The output is **not compilable**. It is a starting point for the human:
Expand All @@ -29,6 +32,24 @@ migration-considerations block; the middle is a `module` stub with
`TODO`s. The human picks the decomposition; the tool surfaces what
needs re-decomposing.

### Detection engines

| `--engine` | Implementation | When to use |
|---|---|---|
| `scanner` (default) | Line-anchored regex over the raw source (`scanner.ml`). | Default — no prerequisites, ships with the binary. |
| `walker` | Shells out to the vendored `tree-sitter` CLI, walks the AST (`walker.ml`). | When the regex's false-positive surface matters — eliminates the `let _ = chained.call()` class of misfire that the column-0 anchor in #319 had to band-aid. |

The walker requires the vendored `tree-sitter-rescript` grammar to be
built first:

```sh
just install-grammar
# or: ./editors/tree-sitter-rescript/scripts/install.sh
```

If the grammar isn't built or the `tree-sitter` CLI isn't on PATH, the
walker auto-falls-back to the scanner and prints the reason to stderr.

## What gets flagged (Phase 1)

The six anti-patterns surfaced in the
Expand All @@ -48,6 +69,24 @@ Deferred to Phase 2 (need real AST):
inside one record literal (collapse to a row-polymorphic record).
- **oversized function** — function body > ~50 LOC (decompose).

### Walker coverage (Phase 2)

| Anti-pattern | Scanner (regex) | Walker (AST) |
|---|---|---|
| `side-effect-import` | ✓ | ✓ — Phase 2b |
| `raw-js` | ✓ | — Phase 2c |
| `untyped-exception` | ✓ | — Phase 2c |
| `mutable-global` | ✓ | — Phase 2c |
| inline lambda callback record | — | — Phase 2c |
| oversized function | — | — Phase 2c |

The walker improves on the regex by being structural: it only reports
`side-effect-import` when `let _ = Mod.value` sits at module top level
(direct child of `source_file` or a `module_declaration` body), not when
the same shape appears inside a function body — where it is normally a
ReScript "discard the return value of a chained call" idiom, not a
module-load side effect.

## Why a skeleton and not a transliteration

The Frontier Programming Guides' standing rule is **re-decompose, not
Expand Down
4 changes: 2 additions & 2 deletions tools/res-to-affine/dune
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@

(library
(name res_to_affine)
(modules scanner emitter)
(libraries str))
(modules scanner emitter walker)
(libraries str unix))
51 changes: 47 additions & 4 deletions tools/res-to-affine/main.ml
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,30 @@ let write_file path contents =
output_string oc contents;
close_out oc

let run input output_opt =
type engine = Scanner_engine | Walker_engine

let engine_label = function
| Scanner_engine -> "scanner"
| Walker_engine -> "walker"

let run engine grammar_dir input output_opt =
if not (Sys.file_exists input) then begin
Format.eprintf "res-to-affine: input not found: %s@." input;
exit 2
end;
let source = read_file input in
let findings = Scanner.scan source in
let findings =
match engine with
| Scanner_engine -> Scanner.scan source
| Walker_engine ->
(try Walker.scan ~grammar_dir ~path:input ~source with
| Failure msg ->
Format.eprintf "res-to-affine: %s@." msg;
Format.eprintf
"res-to-affine: falling back to scanner engine for %s@."
input;
Scanner.scan source)
in
let module_name = Emitter.module_name_of_path input in
let out =
Emitter.emit
Expand All @@ -48,9 +65,10 @@ let run input output_opt =
| Some path ->
write_file path out;
Format.printf
"res-to-affine: %d finding%s → %s@."
"res-to-affine: %d finding%s [%s] → %s@."
(List.length findings)
(if List.length findings = 1 then "" else "s")
(engine_label engine)
path

(* ---- cmdliner wiring ---- *)
Expand All @@ -67,11 +85,36 @@ let output_arg =
value & opt (some string) None &
info ["o"; "output"] ~docv:"FILE" ~doc)

let engine_arg =
let doc =
"Detection engine: 'scanner' (default, line-regex, Phase 1) or \
'walker' (tree-sitter AST, Phase 2). The walker requires the \
vendored grammar to be built — see `just install-grammar`. \
Falls back to 'scanner' if the grammar is missing or \
tree-sitter parse fails."
in
Cmdliner.Arg.(
value & opt
(enum ["scanner", Scanner_engine; "walker", Walker_engine])
Scanner_engine &
info ["engine"] ~docv:"ENGINE" ~doc)

let grammar_dir_arg =
let doc =
"Path to the generated tree-sitter-rescript grammar directory \
(the output of `just install-grammar`). Only consulted when \
`--engine=walker`. Defaults to `tools/vendor/tree-sitter-rescript`."
in
Cmdliner.Arg.(
value & opt string Walker.default_grammar_dir &
info ["grammar-dir"] ~docv:"DIR" ~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 $ input_arg $ output_arg)
Cmdliner.Term.(
const run $ engine_arg $ grammar_dir_arg $ input_arg $ output_arg)
in
Cmdliner.Cmd.v info term

Expand Down
4 changes: 2 additions & 2 deletions tools/res-to-affine/test/dune
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
; SPDX-License-Identifier: MPL-2.0

(test
(name test_emit)
(tests
(names test_emit test_walker)
(libraries res_to_affine alcotest)
(deps
(glob_files fixtures/*.res)
Expand Down
Loading