diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7688d008b..1a583304d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,7 +113,7 @@ jobs: strategy: fail-fast: false matrix: - target: [unit, stdlib, examples] + target: [unit, stdlib, examples, test-llm, test-mcp] steps: - uses: actions/checkout@v4 - uses: actions/download-artifact@v4 diff --git a/Makefile b/Makefile index 7cc482452..c8e7c3a5b 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,15 @@ CHECK += --bin=./ec.native CHECK += --jobs="$(ECJOBS)" CHECK += $(foreach arg,$(ECARGS),--bin-args="$(arg)") CHECK += $(ECEXTRA) config/tests.config +LLMCHECK := scripts/testing/llm-golden +LLMCHECK += --bin=./ec.native +LLMWARM := scripts/testing/llm-warm-reload +LLMWARM += --bin=./ec.native +MCPCHECK := scripts/testing/mcp-golden +MCPCHECK += --bin=./ec.native +MCPPARITY := scripts/testing/mcp-parity +MCPSESSIONS := scripts/testing/mcp-sessions +MCPPARITY += --bin=./ec.native NIX ?= nix --extra-experimental-features "nix-command flakes" PROFILE ?= dev @@ -20,6 +29,7 @@ UNAME_S = $(shell uname -s) # -------------------------------------------------------------------- .PHONY: default build byte native tests check examples +.PHONY: test-llm test-mcp .PHONY: nix-build nix-build-with-provers nix-develop .PHONY: clean install uninstall @@ -49,7 +59,16 @@ stdlib: build examples: build $(CHECK) examples mee-cbc -check: unit stdlib examples +test-llm: build + $(LLMCHECK) + $(LLMWARM) + +test-mcp: build + $(MCPCHECK) + $(MCPPARITY) + $(MCPSESSIONS) + +check: unit stdlib examples test-llm test-mcp @true nix-build: diff --git a/README.md b/README.md index 9ef3d2917..49159d0c0 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,15 @@ with proof scripts). At present, the only available front-end is based on Emacs' [Proof General](https://github.com/ProofGeneral/PG). However, a front-end for VSCode is currently in development. +Besides these, EasyCrypt ships an interface aimed at LLM agents rather +than at humans: `easycrypt llm`, an interactive REPL speaking a +machine-friendly protocol, and `easycrypt mcp`, a +[Model Context Protocol](https://modelcontextprotocol.io/) server over +stdio (`easycrypt mcp -sessions` serves one engine per named session, +for clients that run several agents at once). Both drive the same +proof engine, and both are documented in +[doc/llm/CLAUDE.md](doc/llm/CLAUDE.md). + ### Proof-General (Emacs) EasyCrypt mode has been integrated upstream. Please, go diff --git a/doc/llm/CLAUDE.md b/doc/llm/CLAUDE.md index 0cc20c5a3..639f71764 100644 --- a/doc/llm/CLAUDE.md +++ b/doc/llm/CLAUDE.md @@ -6,59 +6,744 @@ computations, program logics (Hoare logic, probabilistic Hoare logic, probabilistic relational Hoare logic), and ambient mathematical reasoning. -## Using the `llm` command +## Using the `llm` interactive mode -The `llm` subcommand is designed for non-interactive, LLM-friendly -batch compilation. It produces no progress bar and no `.eco` cache -files. +The `llm` subcommand provides an interactive REPL with a +machine-friendly protocol designed for LLM agents. The LLM sends +commands over stdin and receives structured responses on stdout. The +same engine is also served over the Model Context Protocol by the `mcp` +subcommand — see [the MCP section](#using-the-mcp-mode) below. The +two are front-ends over one core, so everything said here about state, +uuids and the proof workflow holds there as well. ``` -easycrypt llm [OPTIONS] FILE.ec +easycrypt llm [OPTIONS] ``` -### Options +Standard loader and prover options (`-I`, `-timeout`, `-p`, etc.) are +available. Use `-help` to print this guide and exit: -- `-upto LINE` or `-upto LINE:COL` — Compile up to (but not - including) the given location, then print the current goal state to - stdout and exit with code 0. Use this to inspect the proof state at - a specific point in a file. +``` +easycrypt llm -help +``` -- `-lastgoals` — On failure, print the goal state (as it was just - before the failing command) to stdout, then print the error to - stderr, and exit with code 1. Use this to understand what the - failing tactic was supposed to prove. +Use `-eval STR` to feed a newline-separated script instead of reading +stdin. Useful for scripted callers and CI: the REPL runs the given +commands and exits (implicit end-of-input, no `QUIT` required): -Standard loader and prover options (`-I`, `-timeout`, `-p`, etc.) are -also available. +``` +easycrypt llm -eval 'LOAD "myfile.ec" 42 +GOALS +COMMIT' +``` + +A `-eval` run exits 1 if any command produced an `ERROR` reply, and 0 +otherwise. The status covers the whole run however it ends: ending the +script with `QUIT`, or with an `exit.` phrase, reports the errors that +came before just as end-of-input does. Interactive sessions (no +`-eval`) always exit 0. + +### Protocol + +**Startup.** EasyCrypt prints a `READY` message and waits for input: + +``` +READY [uuid:0] + +``` + +**Responses.** Every response has a typed envelope and an `` +sentinel: + +``` +OK [uuid:N] + + +``` + +``` +ERROR [uuid:N] + + +``` + +The `uuid` is a monotonically increasing integer identifying the proof +engine state. It increments with each successful command that changes +that state. Queries do not change it: `SEARCH`, and the `search`, +`print` and `locate` statements, report the uuid they were called at. + +**Escaping.** A body may itself contain a line that looks like an +envelope — this document does, and `HELP` prints this document. Call a +line *envelope-shaped* when, after dropping any leading spaces, it is +exactly `` or starts with `OK [uuid:`, `ERROR [uuid:` or +`READY [uuid:`. Every envelope-shaped **body** line is written with one +extra leading space, so a lone `` inside a frame is always the +sentinel and nothing else. To recover the original text, drop one +leading space from each body line that is envelope-shaped, and leave +every other line untouched. Status lines are not body lines and are +never escaped. + +### Meta-commands + +These are protocol-level commands, not EasyCrypt syntax: + +| Command | Description | +|---------|-------------| +| `LOAD "file.ec" [LINE[:COL]] [-nosmt] [-noproof] [-trace]` | Reset state, compile file (optionally weaken SMT, skip the prefix's proofs, or trace the last sentence) | +| `UNDO` | Undo the last proof step | +| `REVERT ` | Revert to a specific state (by uuid or checkpoint name) | +| `GOALS` | Print the current goal (first subgoal only, with remaining count) | +| `GOALS ALL` | Print all subgoals | +| `TREE` | List open subgoals with dotted-path labels showing nesting, marking the focused one | +| `TREE ALL` | Same as `TREE`, but with full goal bodies | +| `FOCUS P` | Focus the leaf at `TREE` path `P` (`N` or `N1.N2.N3...`); the path walks the tree, so a single `N` picks the `N`-th **top-level node**, not the `N`-th goal | +| `NEXT` | Focus the next open subgoal, in `GOALS ALL` order. A different operation from `FOCUS 2` — see below | +| `COMMIT` | Emit recorded REPL phrases as a bulleted proof body (works under `+strict_bullets`) | +| `CHECKPOINT ` | Save current uuid under a name for later `REVERT` | +| `SEARCH ` | Search for lemmas matching a pattern (read-only: the uuid does not move) | +| `QUIET ON` / `QUIET OFF` | Suppress/enable automatic goal display after tactics | +| `STRICT ON` / `STRICT OFF` | Stop the session at a failure, instead of carrying on from wherever it left the engine | +| `RESUME` | Release a `STRICT` stop without moving the engine | +| `` / `` | Delimit multi-line EasyCrypt input | +| `HELP` | Print this guide | +| `QUIT` | Exit | + +### EasyCrypt commands + +Any line that is not a meta-command is parsed as EasyCrypt input. +This covers tactics, declarations, `search`, `print`, `require`, +etc. Every statement on the line must be complete and end with `.` + +``` +smt(). +rewrite H1 H2. +search (%/). +print mulzK. +``` + +A line may hold several statements; all of them are executed, in +order, exactly as if the text had been appended to the source file, +and a single reply describes the state they leave behind: + +``` +split. trivial. trivial. +``` + +If one of them fails, the reply is that failure and the statements +before it stay applied — again as in a file. `exit.` ends the session +there, with the statements that preceded it applied. + +For multi-line statements, wrap with `` and ``: + +``` + +lemma test : + 0 <= n => + 0 < n + 1. + +``` + +### Workflow + +**1. Load a file up to the proof point:** + +``` +LOAD "myfile.ec" 42 +``` + +This compiles the file through line 42 (processing any command whose +end is on or before that line). The response includes where it +stopped: + +``` +OK [uuid:15] [loaded:myfile.ec:42] +Current goal +... + +``` + +For large files, use `-nosmt` to skip SMT calls during prefix +compilation (safe when the prefix was already verified): + +``` +LOAD "myfile.ec" 436 -nosmt +``` -### Output conventions +`-noproof` goes further and skips the prefix's **proofs** altogether: -- **Goals** are printed to **stdout**. -- **Errors** are printed to **stderr**. -- **Exit code 0** means success (or `-upto` reached its target). -- **Exit code 1** means a command failed. -- If there is no active proof at the point where goals are requested, - stdout will contain: `No active proof.` +``` +LOAD "myfile.ec" 436 -noproof +``` -### Workflow for writing and debugging proofs +Every lemma before the target is admitted on its statement alone — its +script is not run, not even typed — exactly as a `require`d file's +lemmas are. The one exception is the proof line 436 falls inside: that +one is replayed for real, so the goal state you land on is the true +one. Positions outside any proof skip the whole file. -1. Try to write a pen-and-paper proof first. +This is the fastest way into a proof in a long file, and it is a large +margin: replaying `theories/datatypes/List.ec` up to line 1487 takes +around 8s plainly, 1.3s under `-nosmt` and 0.5s under `-noproof`, for +byte-identical goals. `-nosmt` only silences the provers; `-noproof` +also skips the elaboration of every tactic in the prefix, which is +where the rest of the time goes. -2. Write the `.ec` file with your proof attempt. For a large proof, - write down skeleton and `admit` subgoals first, and then detail - the proof. +What you give up is any assurance about the prefix: a `-noproof` load +succeeds on a file whose earlier proofs are broken, so it is no +evidence that the file compiles. Replies say so — the tag carries +`[noproof]`: -3. Run `easycrypt llm -lastgoals FILE.ec` to check the full file. - - If it succeeds (exit 0), you are done. - - If it fails (exit 1), read the error from stderr and the goal - state from stdout to understand what went wrong. +``` +OK [uuid:1295] [loaded:myfile.ec:436] [noproof] [focus: 1/2] +``` + +Skipping ends with the LOAD. Whatever you type next is checked +normally, and so is anything you `COMMIT` and put back in the file. A +prefix holding an `undo` is loaded with checking on throughout — the +flag is then silently a no-op, which the missing `[noproof]` tag +reports. + +**Reloading is much cheaper than loading.** What a LOAD costs is +almost never the file: it is the theories the file `require`s, read +from source because nothing used to keep them from one LOAD to the +next. A session keeps them now, so the second LOAD and every one after +it skip that work. On a 470-line development over the Jasmin +libraries, a LOAD into the last proof went from 33s every time to 33s +once and then 2s — and that 2s is the target proof being replayed, +nothing else. + +So stay in one session and reload freely. Editing the file and +LOADing it again is a normal move now, not the expensive one; after an +edit it is often simpler than reverting to a checkpoint, and it is the +only way to see the edit at all, a session holding the file as it was +read. + +Edits are noticed. A theory is kept only while the file it came from, +and every file below it, is byte-for-byte what it was when it was +read; change any of them and it is read again. A LOAD after an edit +therefore shows the edit, whether you edited the file being loaded, a +theory it requires, or a theory five requires down. Changing the +include path starts over likewise, so nothing is ever served across +two developments that happen to name a theory the same way. That is +narrower than it sounds: a directory the session has already searched +is not a change, so loading file after file of one project — which is +what a session does — keeps everything. + +What none of this makes cheap is `require`ing a file that does not +compile: a file that fails produces no theory to keep, so a session +whose dependency is mid-edit pays for it on every LOAD. Worth knowing +when a reload that should be instant is not — the file below is +probably failing. + +Add `-trace` to a LOAD to inspect the proof state around the last +loaded sentence. The reply body contains four delimited blocks: + +``` +LOAD "myfile.ec" 42 -trace + +=== BEFORE: line 42 (col 0) === + +=== TACTIC (lines 42:0 - 42:10) === + +=== AFTER: line 42 (col 0) === + +=== SUMMARY === +open goals: N1 -> N2 +``` + +The position comes from the existing `LINE[:COL]` argument; omit it to +trace the file's last sentence. On tactic failure the reply uses the +`ERROR` envelope and still includes the BEFORE/TACTIC blocks plus an +`` marker in the AFTER block. + +A `-trace` LOAD that cannot trace at all (the target sentence is not +inside a proof, or there is no sentence to trace) reports the error but +still leaves the session where the same LOAD without `-trace` would: +you can carry on from there instead of reloading. + +**2. Try tactics, using REVERT to restart:** + +The uuid returned by LOAD is a revertible state. Use `REVERT` to +return to it after failed experiments — this is instant, unlike +re-doing LOAD which recompiles the prefix. + +``` +LOAD "myfile.ec" 42 +→ OK [uuid:15] [loaded:myfile.ec:42] + +smt(). ← fails, state unchanged +rewrite H1. smt(). ← succeeds (uuid:17) +rewrite H2. ← wrong direction +REVERT 17 ← back to after the successful smt() +``` + +To restart the proof from scratch, revert to the LOAD uuid: + +``` +REVERT 15 ← back to the state right after LOAD +``` + +Always note the LOAD uuid so you can return to it. + +**3. Use checkpoints for branching exploration:** + +``` +CHECKPOINT before_split +split. +smt(). ← fails +REVERT before_split +apply H. ← try a different approach +``` -4. Use `-upto LINE` to inspect the proof state at a specific point - without running the rest of the file. This is useful for - incremental proof development. +**4. Inspect and navigate nested subgoals with `TREE` and `FOCUS`:** -5. Fix the proof and repeat from step 2. The ultimate proof should - not contain `admit` or `admitted`. +When a tactic opens multiple subgoals, the engine focuses the first +one. By default subsequent tactics act on it; siblings wait their +turn. Use `TREE` to see the structure, including nested splits: + +``` +TREE +→ OK [uuid:N] [focus: 1/4] + [1.1.1] x = 0 <- focused + [1.1.2] y = 1 + [1.2] z = 2 +[2] w = 3 + +``` + +The labels are dotted paths. `FOCUS P` rotates focus to the leaf at +path `P`: + +``` +FOCUS 1.2 ← work on `z = 2` +FOCUS 2 ← work on `w = 3` +FOCUS 1.1.1 ← back to `x = 0` +``` + +A `FOCUS` path always walks the tree, one component per level, so a +single integer `k` names the **k-th top-level node** — not the k-th +open goal. The tree above has four open goals but only two top-level +nodes, `[1]` (a frame) and `[2]` (a leaf), so: + +``` +FOCUS 2 ← `w = 3`, the second top-level node +FOCUS 3 ← ERROR: FOCUS: index 3 out of range (1..2) +FOCUS 1 ← ERROR: FOCUS: path must select a leaf goal, not a frame +``` + +`NEXT` is a different operation, and **not** shorthand for `FOCUS 2`: +it moves to the next open subgoal in `GOALS ALL` order, whatever the +nesting. From the tree above, `NEXT` focuses `y = 1` (`[1.1.2]`) while +`FOCUS 2` focuses `w = 3`. The two agree only when the tree is flat — +one `split.`, two leaves at the top level — which is the common case, +and the reason the difference is easy to miss. + +Replies carry a `[focus: k/N]` tag when more than one goal is open +(e.g. `OK [uuid:42] [focus: 1/3]`) so you always know which goal the +next tactic will hit. **TREE labels are not stable across focus +changes** — `FOCUS 1.2` from one state may name a different goal in +another, because the tree always shows the focused goal first. + +**5. Build a `+strict_bullets`-friendly proof with `COMMIT`:** + +The REPL records every successful interactive phrase except queries +(`search`, `print`, `locate`, and the `SEARCH` command), so you can +look things up mid-proof without polluting the body. `COMMIT` walks +the proof DAG and emits the recorded tactics with bullets inserted +at every multi-child split. The output is a proof body that compiles +under `pragma +strict_bullets`: + +``` +LOAD "myfile.ec" 42 +split. +- rewrite H. trivial. ← REPL accepts the unbulleted form +- exact hq. +COMMIT +→ OK [uuid:N] +split. +- rewrite H. trivial. +- exact hq. + +``` + +Bullet characters cycle through `-`, `+`, `*`, `--`, `++`, `**`, ... +and are chosen to avoid colliding with any frames the LOAD prefix +already opened. Use `COMMIT` once the proof is complete (or at any +checkpoint) and paste the result back into the source file. Running +`COMMIT` after `qed.` still emits a bulleted body: the proof structure +is read from a snapshot taken while the proof was open. + +`UNDO` / `REVERT` trim the COMMIT transcript automatically. + +**6. Use `STRICT ON` if you send one phrase at a time:** + +A session behaves as a source file does: a failing phrase is reported, +and whatever you send next runs against wherever that failure left the +engine. Sending phrases one at a time and acting on each reply, that +is a trap. `split.` opens two goals, the tactic after it fails, and +the phrase after *that* lands on the first subgoal rather than where +you wrote it for. Nothing says so; the proof simply stops making +sense several phrases later. + +`STRICT ON` stops the session at the failure instead: + +``` +STRICT ON +split. +apply etrivial. ← fails, having left two goals open +trivial. +→ ERROR [uuid:42] + strict: the session stopped at a failed phrase and has not been + resynchronized + stopped at: apply etrivial. + UNDO, REVERT, LOAD or RESUME to continue; GOALS, TREE, SEARCH and + COMMIT answer meanwhile +``` + +Being stopped is not being locked out: `GOALS`, `TREE`, `SEARCH`, +`CHECKPOINT` and `COMMIT` all answer, which is the point — you are +meant to look at the failure. What is refused is anything that would +move the engine further. To carry on, either go somewhere definite +(`UNDO`, `REVERT`, `LOAD`) or say you meant to stay (`RESUME`). + +`RESUME` fails if the session was not stopped, and so does `STRICT +OFF` release any stop: a session that does not stop at failures cannot +be sitting at one. + +Over MCP the mode is `ec_strict` and the release is `ec_resume`, and +there `ec_try` earns its keep: a failing `ec_try` never stops the +session, its contract being that a failure leaves the engine exactly +where it was, so there is no drift to prevent. It is still refused +*while* stopped, since succeeding would advance from a point you have +not acknowledged. + +**7. Use QUIET mode to save tokens during bulk tactic application:** + +``` +QUIET ON +rewrite H1. +rewrite H2. +rewrite H3. +QUIET OFF +GOALS +``` + +**8. Search for lemmas using patterns:** + +EasyCrypt `search` uses pattern syntax, not keywords. Use `_` as +wildcard: + +``` +search (fdom _). ← lemmas involving fdom +search (_ %/ _). ← integer division lemmas +search (card (_ `|` _)). ← card of union +search (mu _ _) (_ <= _). ← mu lemmas with inequalities +``` + +The SEARCH meta-command is a shorthand that adds `search`/`.`: + +``` +SEARCH (fdom _) +SEARCH (_ %/ _) +``` + +## Using the MCP mode + +The `mcp` subcommand serves the same proof engine over the [Model +Context Protocol](https://modelcontextprotocol.io/) instead of the +text protocol above: JSON-RPC 2.0 messages, one per line, over stdio. +Use it from a client that already speaks MCP; use `llm` for the raw +protocol, as a debug console, or for `-eval` scripting. + +``` +easycrypt mcp [OPTIONS] +easycrypt mcp -sessions [-idle ] [-logdir ] [OPTIONS] +``` + +The first form is one engine for one client; the second, one engine +per named session behind one server, for clients that run several +agents at once (see "Multi-agent sessions" below). The same loader +and prover options as `llm` are available in both (`-I`, `-timeout`, +`-p`, `-stdlib`, etc.). Use `-help` to print this section and exit: + +``` +easycrypt mcp -help +``` + +Only protocol messages appear on stdout; everything the engine has to +say goes to stderr. The server speaks the `initialize` / +`notifications/initialized` handshake, implements `initialize`, +`ping`, `tools/list` and `tools/call`, and tolerates notifications as +no-ops. It advertises the `tools` capability and nothing else: no +resources, no prompts, no sampling. + +### Tools + +Thirteen tools. Required arguments are marked; the others default as +noted. Under `-sessions`, every one of them takes a required `session` +as well, and two more tools appear, `ec_sessions` and `ec_close`; see +"Multi-agent sessions" below. + +| Tool | Arguments | Description | +|------|-----------|-------------| +| `ec_load` | `file` (req), `line`, `col`, `nosmt` (false), `noproof` (false), `trace` (false) | Reset the session and compile `file` from the top, stopping after the last sentence that ends on or before `line` | +| `ec_step` | `phrase` (req) | Run EasyCrypt sentences — tactics, declarations, `require`, `print`, ... — against the current session | +| `ec_try` | `phrase` (req) | Like `ec_step`, but roll the engine back to its pre-call state whenever a sentence fails | +| `ec_goals` | `all` (false) | Print the focused subgoal, or, with `all`, every open subgoal | +| `ec_tree` | `full` (false) | List the open subgoals as a tree of dotted-path labels, marking the focused one | +| `ec_focus` | `path` (req) | Rotate the focus onto the subgoal at dotted path `path`, or onto the next one with `"next"` | +| `ec_undo` | — | Undo the last engine step | +| `ec_revert` | `target` (req) | Return the session to an earlier state, named by a uuid or by a checkpoint name | +| `ec_checkpoint` | `name` (req) | Record the current uuid under `name`, for a later `ec_revert` | +| `ec_commit` | — | Emit the phrases recorded since the last `ec_load` as a bulleted proof body | +| `ec_strict` | `on` (req) | Stop the session at a failure, instead of carrying on from wherever it left the engine | +| `ec_resume` | — | Release a strict-mode stop without moving the engine | +| `ec_search` | `pattern` (req) | Search the environment for lemmas matching an EasyCrypt search pattern | + +`tools/list` carries a fuller, agent-facing `description` and a JSON +Schema for every tool; those are the authoritative texts. The tools +mirror the REPL meta-commands — `-nosmt`, `-noproof`, `-trace`, dotted paths, +checkpoints, bullets, strict mode and search patterns all behave +exactly as described above, and `NEXT` folds into `ec_focus` with path +`"next"` — plus `ec_try`, which has no REPL equivalent. The meta-commands that are +pure console affordances have no tool: multi-line input needs no +``/`` (a `phrase` may simply contain newlines), `QUIET` +has no purpose when the client decides what to display, `HELP` is this +section, and the session ends when the client closes stdin — or when a +phrase is `exit.`, which answers `session terminated` and stops the +process. + +### Running sentences + +`ec_step` takes one or more complete EasyCrypt sentences in a single +`phrase`, exactly as a REPL line does: all of them are executed, in +order, as if the text had been appended to the source file, and one +reply describes the state they leave behind. If one of them fails, the +reply is that failure, the sentences before it stay applied, and the +engine is left wherever the failing sentence left it. + +`ec_try` runs the same input under a rollback contract: whenever a +sentence fails, the engine is returned to the state it had before the +call — including input that failed only after having already advanced +the proof. The failure result sets `structuredContent.reverted` to +`true`, and its `uuid` and text describe that restored state, not the +point of failure. Use `ec_try` to probe a tactic without having to +`ec_revert` afterwards, and `ec_step` when you mean to keep whatever +progress the phrase makes. A successful phrase behaves identically +under both, and is recorded for `ec_commit` in both. + +### State and uuids + +The state model is the REPL's, unchanged. One client is one process is +one engine state: tool calls run strictly in arrival order even when a +client pipelines them. Several agents sharing one client need one +engine each; that is what `-sessions` provides (see below). Every result +reports in its `structuredContent` the `uuid` the call left behind — +the same monotonically increasing state identifier the REPL prints as +`[uuid:N]`, advancing only on calls that change engine state — and +`ec_revert` accepts either one of those uuids or a name given to +`ec_checkpoint`. Note the uuid returned by `ec_load`: reverting to it +is the instant way back to the start of the proof. + +### Errors + +Two kinds of failure, deliberately kept apart: + +* **Protocol faults** — malformed JSON, an unknown method, an unknown + tool, an argument that violates the declared schema — are JSON-RPC + errors (`-32700`, `-32600`, `-32601`, `-32602`). +* **EasyCrypt failures** — a tactic that does not apply, a file that + does not compile, an SMT timeout, a file that is not there — are + *successful* responses carrying `"isError": true` and the prover's + error text. + +The second kind is data: read those messages and act on them, the way +the REPL's `ERROR` replies are meant to be read. + +### Result shape + +Every `tools/call` result, error or not, has the same shape: + +```json +{"content": [{"type": "text", "text": "Current goal\n..."}], + "structuredContent": {"text": "Current goal\n...", "uuid": 3, + "changed": true}, + "isError": false} +``` + +`text` is the body the REPL would print between its envelope and +``, `uuid` is the resulting state, and `changed` says whether the +engine advanced; `ec_try` adds `reverted` on failure, and each tool +declares an `outputSchema` matching that structured half. The text +appears twice on purpose: some clients hand the model +`structuredContent` alone and drop `content` whenever both are present, +so a payload living only in `content` would never reach the agent (the +measurement is in `tests/mcp/README.md`). + +What has no counterpart here are the REPL's status-line annotations: +`[loaded:file:N]` and the `[focus: k/N]` tag do not ride along, so ask +`ec_tree` when you need to know which of several goals the next tactic +will hit. + +### Client configuration + +As a project-scoped `.mcp.json`, dropped next to a proof development: + +```json +{ + "mcpServers": { + "easycrypt": { + "command": "easycrypt", + "args": ["mcp"] + } + } +} +``` + +Add loader options to `args` as needed, e.g. `["mcp", "-I", +"theories"]`. The equivalent one-liner, for Claude Code: + +``` +claude mcp add easycrypt -- easycrypt mcp +``` + +### Multi-agent sessions + +An MCP client such as Claude Code opens one connection per configured +server and lets every agent it runs share it, and the stdio transport +carries no caller identity. Two agents driving one `easycrypt mcp` +therefore clobber each other's goals. The fix is at the protocol +level: + +``` +easycrypt mcp -sessions [-idle ] [-logdir ] +``` + +runs a *multiplexer* instead of an engine: the same tools, each with +one more required argument, `session`, naming the engine the call +runs in. The first call naming a session starts a child +`easycrypt mcp` — the single-engine server above, with the loader and +prover options the multiplexer received — and every later call naming +it is forwarded there. Sessions are independent processes: their own +loaded file, uuids, checkpoints, strict mode and memory. Calls to +different sessions run in parallel; calls to the same session run in +arrival order, as before. `initialize`, `tools/list` and `ping` are +answered by the multiplexer itself, under the same server name, so a +client's approval of the server carries over. + +**The rule for agents: one session name per agent, and never another +agent's.** Use your agent tag, or any name that is yours alone, in +every call. A call without `session` is refused with a tool-level +error, as is a name that is not made of letters, digits, `_`, `-` and +`.` (at most 64 characters). Two agents that share a name share an +engine and are back to clobbering each other. + +Two tools belong to the multiplexer: + +| Tool | Arguments | Description | +|------|-----------|-------------| +| `ec_sessions` | — | List the live sessions, one line each: `NAME pid PID idle Ns`, with `(dead)` appended when the engine has exited; or `no live session` | +| `ec_close` | `session` (req) | Kill that session's engine and forget it; answers `closed NAME` or ``no session `NAME'`` | + +A session unused for `-idle` minutes (default 180) is killed, unless a +call is running in it; a loaded large development is a lot of resident +memory, and only a process exit gives it back. Close your own session +with `ec_close` when you are done with it. A session whose engine died +— killed, timed out, or stopped by an `exit.` phrase — is dropped, and +the next call naming it starts a fresh engine, which needs an +`ec_load` again; a call that finds the engine gone says so in a +tool-level error rather than failing silently. + +Each child's stderr — the engine's own chatter, which the single +server also writes to stderr — goes to `/ec-mcp-.log`, +with `-logdir` defaulting to `$TMPDIR`, else `/tmp`. The +multiplexer's stdout carries the protocol and nothing else. When the +client closes the connection, or the multiplexer is terminated, every +child is killed with it. + +A ready-to-use client configuration: + +```json +{"mcpServers": {"easycrypt": {"command": "easycrypt", + "args": ["mcp", "-sessions"]}}} +``` + +## EasyCrypt proof strategy + +### General approach + +- Start with a pen-and-paper proof plan before writing tactics. +- Use `smt()` aggressively. Try it first — if it fails, add hints: + `smt(lemma1 lemma2)`. +- Build proofs with `have` assertions. Establish intermediate facts + as named hypotheses, then combine with `smt()`. Avoid long rewrite + chains. +- Case split early: `case (n = 0) => [->|hn0].` Base cases often + close by computation. +- Provide specific instances of lemmas to smt: + `have h := lemma arg1 arg2.` SMT works much better with ground + instances than with universally quantified axioms. + +### Integer division (`%/`) + +- `divzK`: `d %| m => m %/ d * d = m` — recovering from exact + division +- `mulzK`: `d <> 0 => m * d %/ d = m` — canceling a known factor +- `divzMpl`: `0 < p => p * m %/ (p * d) = m %/ d` — simplifying + common factors +- To prove `a %/ d = x`, establish `a = x * d` (with `d %| a`), + then use `mulzK`. +- Don't try to rewrite inside `%/` expressions directly. Instead, + prove the equality as a `have` and use it. + +### What works, what doesn't + +- `ring` solves polynomial equalities over integers but treats + abstract ops (like `fact`) as opaque. It **cannot** simplify + `fact(n-1+1)` to `fact(n)`. +- `smt()` can do linear arithmetic and combine hypotheses, but + struggles with nonlinear integer division. Pre-compute key facts + with `have` and `divzK`/`mulzK`, then let smt combine them. +- `rewrite {k}h` rewrites the k-th occurrence only. Essential when a + term appears on both sides of an equation. +- For induction on naturals: `elim/natind: n` gives base (`n ≤ 0`) + and step (`0 ≤ n → P n → P (n+1)`). + +### SMT usage + +`smt()` and `/#` are equivalent — both call external SMT solvers. + +- Use `smt()` **only** on goals that are pure arithmetic or pure + propositional logic. If the goal contains abstract operators, + FMap terms, or `if-then-else`, reduce it first with `rewrite`, + `case`, or `have` before calling `smt()`. +- If `smt()` takes more than 1 second, the goal is too complex. + Simplify with interactive tactics instead of increasing the + timeout. + +### Common pitfalls + +- `rewrite (factS n) //` generates a side goal `0 <= n`. Use + `first smt()` or provide the precondition explicitly. +- `by` closes **all** remaining subgoals. If it fails, the error + refers to the first unclosed goal, which may not be the intended + one. +- When a tactic generates multiple subgoals, the engine focuses the + first one. Address them in any order via `FOCUS path`, or in the + default order by closing each in turn. Use `TREE` or `GOALS ALL` + to see what's open. +- When more than one subgoal is open, every `OK` reply that reflects + proof state -- tactics, `GOALS`, `GOALS ALL`, `TREE`, `TREE ALL`, + `FOCUS`, `NEXT`, `COMMIT`, `LOAD` -- carries a `[focus: k/N]` tag + (e.g. `OK [uuid:42] [focus: 1/3]`) so you know which one the next + tactic will hit. `HELP`, `QUIET` and `CHECKPOINT` are untagged. +- `pragma +strict_bullets` does **not** apply to REPL input. Files + loaded via `LOAD` still respect their own pragmas, but tactics typed + at the REPL prompt are never rejected for missing bullets — the + REPL is the focus mechanism. +- `rewrite lemma in H` modifies hypothesis `H` in place (it does + not consume it). If you need to preserve the original, copy it + first: `have H' := H; rewrite lemma in H'`. ## EasyCrypt language overview @@ -91,8 +776,6 @@ proof. by ring. qed. ### Common tactics - - - `trivial` — solve trivial goals - `smt` / `smt(lemmas...)` — call SMT solvers, optionally with hints - `auto` — automatic reasoning @@ -141,9 +824,10 @@ proof. by ring. qed. ### Guidelines -* Use SMT solver only in direct mode (smt() or /#) on simple goals (arithmetic goals, pure logical goals). +* Use SMT solver only in direct mode (smt() or /#) on simple goals + (arithmetic goals, pure logical goals). * Refrain from unfolding operator definitions unless necessary. - If you need more properties on an operator, state this property in a dedicated lemma, - but avoid unfolding definitions in higher level proofs. - + If you need more properties on an operator, state this property + in a dedicated lemma, but avoid unfolding definitions in higher + level proofs. diff --git a/scripts/testing/llm-golden b/scripts/testing/llm-golden new file mode 100755 index 000000000..98361f38a --- /dev/null +++ b/scripts/testing/llm-golden @@ -0,0 +1,132 @@ +#! /bin/sh + +# -------------------------------------------------------------------- +# Golden-output regression harness for the `easycrypt llm` REPL. +# +# llm-golden [--bin PATH] [--record] [NAME...] +# +# Each tests/llm/scripts/NAME.script holds the newline-separated +# commands fed to `ec.exe llm -eval`. Lines starting with `#` are +# stripped before the script is passed to -eval; the first such line +# must be `# exit: N`, the expected process exit status. Stdout is +# compared against tests/llm/expected/NAME.out. +# +# Scripts run with tests/llm as the working directory, so fixture paths +# stay relative and the [loaded:...] reply tags remain machine +# independent. +# -------------------------------------------------------------------- + +set -u + +root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +bin="$root/_build/default/src/ec.exe" +record=0 +names="" + +while [ $# -gt 0 ]; do + case "$1" in + --bin) + [ $# -ge 2 ] || { echo "llm-golden: --bin needs an argument" >&2; exit 2; } + bin=$2; shift 2 ;; + --bin=*) + bin=${1#--bin=}; shift ;; + --record) + record=1; shift ;; + -h|--help) + echo "usage: llm-golden [--bin PATH] [--record] [NAME...]"; exit 0 ;; + -*) + echo "llm-golden: unknown option: $1" >&2; exit 2 ;; + *) + names="$names $1"; shift ;; + esac +done + +case "$bin" in + /*) ;; + *) bin=$(CDPATH= cd -- "$(dirname -- "$bin")" && pwd)/$(basename -- "$bin") ;; +esac + +if [ ! -x "$bin" ]; then + echo "llm-golden: no such executable: $bin" >&2 + exit 2 +fi + +tests="$root/tests/llm" +scripts="$tests/scripts" +expected="$tests/expected" + +if [ -z "$names" ]; then + names=$(cd "$scripts" && ls *.script 2>/dev/null | sed 's/\.script$//') +fi + +mkdir -p "$expected" + +tmp=$(mktemp -d "${TMPDIR:-/tmp}/llm-golden.XXXXXX") || exit 2 +trap 'rm -rf "$tmp"' EXIT INT TERM + +nfail=0 +npass=0 + +for name in $names; do + script="$scripts/$name.script" + gold="$expected/$name.out" + + if [ ! -f "$script" ]; then + echo "FAIL $name (no such script: $script)" + nfail=$((nfail + 1)) + continue + fi + + want_exit=$(sed -n 's/^# *exit: *\([0-9][0-9]*\).*$/\1/p' "$script" | head -n 1) + if [ -z "$want_exit" ]; then + echo "FAIL $name (script has no '# exit: N' line)" + nfail=$((nfail + 1)) + continue + fi + + grep -v '^#' "$script" > "$tmp/eval.in" + + (cd "$tests" && "$bin" llm -eval "$(cat "$tmp/eval.in")") \ + > "$tmp/out" 2> "$tmp/err" + got_exit=$? + + if [ "$record" = 1 ]; then + cp "$tmp/out" "$gold" + if [ "$got_exit" != "$want_exit" ]; then + echo "RECORD $name (exit $got_exit, script declares $want_exit)" + nfail=$((nfail + 1)) + else + echo "RECORD $name" + npass=$((npass + 1)) + fi + continue + fi + + ok=1 + + if [ ! -f "$gold" ]; then + echo "FAIL $name (no golden: $gold; re-run with --record)" + ok=0 + elif ! diff -u "$gold" "$tmp/out" > "$tmp/diff"; then + echo "FAIL $name (stdout differs)" + sed 's/^/ /' "$tmp/diff" + ok=0 + fi + + if [ "$got_exit" != "$want_exit" ]; then + echo "FAIL $name (exit $got_exit, expected $want_exit)" + ok=0 + fi + + if [ "$ok" = 1 ]; then + echo "PASS $name" + npass=$((npass + 1)) + else + nfail=$((nfail + 1)) + fi +done + +echo "----" +echo "$npass passed, $nfail failed" + +[ "$nfail" = 0 ] diff --git a/scripts/testing/llm-warm-reload b/scripts/testing/llm-warm-reload new file mode 100755 index 000000000..33eb5c64d --- /dev/null +++ b/scripts/testing/llm-warm-reload @@ -0,0 +1,216 @@ +#! /usr/bin/env python3 + +# -------------------------------------------------------------------- +# The theory cache the interactive front-ends run with (EcCommands' +# ThCache) keeps elaborated theories across the scope rebuild a LOAD +# does, so that reloading a file does not re-read everything it +# requires. What it must never do is serve a theory the sources no +# longer describe, and that cannot be checked from a `-eval' script: +# the file has to change *between* two LOADs of one session. So this +# harness drives the REPL over stdin instead. +# +# llm-warm-reload [--bin PATH] [-v] +# +# Four scenarios, on a fixture tree written into a temporary +# directory. Each asserts the same invariant, which is the whole +# contract of the cache: a warm session answers exactly what a cold +# process answers on the same sources, byte for byte. +# +# quiet no edit at all -- the reload the cache exists for +# direct a required file is edited +# deep a file required only through another one is edited +# shadowed the include path changes so a name resolves elsewhere +# +# The edited scenarios also assert the answer *moved*: an invariant +# that only says "warm equals cold" is satisfied by a session that +# reports the same stale thing a cold process would, and a fixture +# whose edit is invisible would pass while testing nothing. +# -------------------------------------------------------------------- + +import argparse +import os +import shutil +import subprocess +import sys +import tempfile + +# -------------------------------------------------------------------- +BASE = """require import AllCore. +op k : int = %s. +""" + +MID = """require import AllCore Base. +op m : int = k + 1. +""" + +TOP = """require import AllCore Mid. + +lemma target : m = k + 1. +proof. +rewrite /m. +trivial. +qed. +""" + +# `Mid' as the shadowing directory writes it: same theory, other body. +MID_SHADOW = """require import AllCore Base. +op m : int = k + 99. +""" + + +# -------------------------------------------------------------------- +class Session: + """A `llm' REPL driven over stdin, one command at a time.""" + + def __init__(self, binary, cwd): + self.p = subprocess.Popen( + [binary, 'llm'], cwd=cwd, text=True, bufsize=1, + stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL) + self.reply() # the READY frame + + def reply(self): + out = [] + while True: + line = self.p.stdout.readline() + if line == '': + raise SystemExit('llm-warm-reload: the engine died') + if line.rstrip('\n') == '': + return ''.join(out) + out.append(line) + + def send(self, command): + self.p.stdin.write(command + '\n') + self.p.stdin.flush() + return self.reply() + + def quit(self): + self.p.stdin.write('QUIT\n') + self.p.stdin.flush() + self.p.wait() + + +# What a session is asked, in every scenario: reload the file, then +# look at the two theories under it -- one required directly, one only +# through the other. +def probe(session, entry='Top.ec'): + return (session.send('LOAD "%s" 5' % entry) + + session.send('print Base.k.') + + session.send('print Mid.m.')) + + +def cold(binary, cwd, entry='Top.ec'): + session = Session(binary, cwd) + answer = probe(session, entry) + session.quit() + return answer + + +# -------------------------------------------------------------------- +def scenario(binary, root, name, verbose): + cwd = os.path.join(root, name) + os.makedirs(cwd) + for (path, text) in [('Base.ec', BASE % '1'), ('Mid.ec', MID), + ('Top.ec', TOP)]: + with open(os.path.join(cwd, path), 'w') as stream: + stream.write(text) + + entry = 'Top.ec' + session = Session(binary, cwd) + before = probe(session) + + if name == 'quiet': + pass + + elif name == 'direct': + # Mid.ec is required by Top.ec itself. + with open(os.path.join(cwd, 'Mid.ec'), 'w') as stream: + stream.write(MID.replace('k + 1', 'k + 5')) + + elif name == 'deep': + # Base.ec is not named by Top.ec at all: it is reached through + # Mid.ec, so serving it from the cache means having decided + # that Mid.ec's own dependencies still hold. + with open(os.path.join(cwd, 'Base.ec'), 'w') as stream: + stream.write(BASE % '42') + + elif name == 'shadowed': + # No file changes: the *include path* does, and under the new + # one `Mid' is another file. Nothing digests differently, so + # only dropping the table on a load-path change gets this + # right. + other = os.path.join(cwd, 'other') + os.makedirs(other) + for (path, text) in [('Base.ec', BASE % '1'), + ('Mid.ec', MID_SHADOW), ('Top.ec', TOP)]: + with open(os.path.join(other, path), 'w') as stream: + stream.write(text) + entry = os.path.join('other', 'Top.ec') + + after = probe(session, entry) + session.quit() + + reference = cold(binary, cwd, entry) + + ok = True + if after != reference: + ok = False + print('FAIL %s (warm session and cold process disagree)' % name) + for line in _diff(reference, after): + print(' ' + line) + if name != 'quiet' and after == before: + ok = False + print('FAIL %s (the change is invisible: the fixture tests ' + 'nothing)' % name) + if name == 'quiet' and after != before: + ok = False + print('FAIL %s (an untouched reload moved)' % name) + for line in _diff(before, after): + print(' ' + line) + + if ok: + print('PASS %s' % name) + if verbose: + for line in after.split('\n'): + print(' ' + line) + return ok + + +def _diff(want, got): + import difflib + return list(difflib.unified_diff( + want.split('\n'), got.split('\n'), + fromfile='cold', tofile='warm', lineterm='')) + + +# -------------------------------------------------------------------- +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + root = os.path.dirname(os.path.dirname(here)) + parser = argparse.ArgumentParser() + parser.add_argument( + '--bin', default=os.path.join(root, '_build/default/src/ec.exe')) + parser.add_argument('-v', '--verbose', action='store_true') + args = parser.parse_args() + + binary = os.path.abspath(args.bin) + if not os.access(binary, os.X_OK): + print('llm-warm-reload: no such executable: %s' % binary, + file=sys.stderr) + return 2 + + tmp = tempfile.mkdtemp(prefix='llm-warm-reload.') + try: + results = [scenario(binary, tmp, name, args.verbose) + for name in ['quiet', 'direct', 'deep', 'shadowed']] + finally: + shutil.rmtree(tmp, ignore_errors=True) + + print('----') + print('%d passed, %d failed' + % (results.count(True), results.count(False))) + return 0 if all(results) else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/testing/mcp-golden b/scripts/testing/mcp-golden new file mode 100755 index 000000000..56ee903e8 --- /dev/null +++ b/scripts/testing/mcp-golden @@ -0,0 +1,140 @@ +#! /bin/sh + +# -------------------------------------------------------------------- +# Golden-output regression harness for the `easycrypt mcp' server. +# +# mcp-golden [--bin PATH] [--record] [NAME...] +# +# Each tests/mcp/scripts/NAME.script holds the newline-delimited +# JSON-RPC messages fed to `ec.exe mcp' on stdin. Lines starting with +# `#' are stripped before the script is handed to the server; the first +# such line must be `# exit: N', the expected process exit status. +# Stdout -- the protocol stream, one JSON message per line -- is +# compared against tests/mcp/expected/NAME.out. +# +# Scripts run with tests/mcp as the working directory, so fixture paths +# stay relative and no golden bakes in a developer's home directory. +# The fixtures are the ones the REPL harness uses, under +# ../llm/fixtures. +# +# One field of the stream is not reproducible: serverInfo.version is a +# git-describe string. It is rewritten to "VERSION" before diffing. +# -------------------------------------------------------------------- + +set -u + +root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +bin="$root/_build/default/src/ec.exe" +record=0 +names="" + +while [ $# -gt 0 ]; do + case "$1" in + --bin) + [ $# -ge 2 ] || { echo "mcp-golden: --bin needs an argument" >&2; exit 2; } + bin=$2; shift 2 ;; + --bin=*) + bin=${1#--bin=}; shift ;; + --record) + record=1; shift ;; + -h|--help) + echo "usage: mcp-golden [--bin PATH] [--record] [NAME...]"; exit 0 ;; + -*) + echo "mcp-golden: unknown option: $1" >&2; exit 2 ;; + *) + names="$names $1"; shift ;; + esac +done + +case "$bin" in + /*) ;; + *) bin=$(CDPATH= cd -- "$(dirname -- "$bin")" && pwd)/$(basename -- "$bin") ;; +esac + +if [ ! -x "$bin" ]; then + echo "mcp-golden: no such executable: $bin" >&2 + exit 2 +fi + +tests="$root/tests/mcp" +scripts="$tests/scripts" +expected="$tests/expected" + +if [ -z "$names" ]; then + names=$(cd "$scripts" && ls *.script 2>/dev/null | sed 's/\.script$//') +fi + +mkdir -p "$expected" + +tmp=$(mktemp -d "${TMPDIR:-/tmp}/mcp-golden.XXXXXX") || exit 2 +trap 'rm -rf "$tmp"' EXIT INT TERM + +nfail=0 +npass=0 + +for name in $names; do + script="$scripts/$name.script" + gold="$expected/$name.out" + + if [ ! -f "$script" ]; then + echo "FAIL $name (no such script: $script)" + nfail=$((nfail + 1)) + continue + fi + + want_exit=$(sed -n 's/^# *exit: *\([0-9][0-9]*\).*$/\1/p' "$script" | head -n 1) + if [ -z "$want_exit" ]; then + echo "FAIL $name (script has no '# exit: N' line)" + nfail=$((nfail + 1)) + continue + fi + + grep -v '^#' "$script" > "$tmp/rpc.in" + + (cd "$tests" && "$bin" mcp < "$tmp/rpc.in") \ + > "$tmp/raw" 2> "$tmp/err" + got_exit=$? + + sed 's/\("serverInfo":{"name":"easycrypt","version":\)"[^"]*"/\1"VERSION"/' \ + "$tmp/raw" > "$tmp/out" + + if [ "$record" = 1 ]; then + cp "$tmp/out" "$gold" + if [ "$got_exit" != "$want_exit" ]; then + echo "RECORD $name (exit $got_exit, script declares $want_exit)" + nfail=$((nfail + 1)) + else + echo "RECORD $name" + npass=$((npass + 1)) + fi + continue + fi + + ok=1 + + if [ ! -f "$gold" ]; then + echo "FAIL $name (no golden: $gold; re-run with --record)" + ok=0 + elif ! diff -u "$gold" "$tmp/out" > "$tmp/diff"; then + echo "FAIL $name (stdout differs)" + sed 's/^/ /' "$tmp/diff" + ok=0 + fi + + if [ "$got_exit" != "$want_exit" ]; then + echo "FAIL $name (exit $got_exit, expected $want_exit)" + ok=0 + fi + + if [ "$ok" = 1 ]; then + echo "PASS $name" + npass=$((npass + 1)) + else + nfail=$((nfail + 1)) + fi +done + +echo "----" +echo "$npass passed, $nfail failed" + +[ "$nfail" = 0 ] diff --git a/scripts/testing/mcp-inspector-check b/scripts/testing/mcp-inspector-check new file mode 100755 index 000000000..9b869110a --- /dev/null +++ b/scripts/testing/mcp-inspector-check @@ -0,0 +1,72 @@ +#! /bin/sh + +# -------------------------------------------------------------------- +# Manual smoke test against a real MCP client. +# +# mcp-inspector-check [--bin PATH] +# +# Drives `easycrypt mcp' with the reference client, the MCP Inspector's +# CLI mode, rather than with our own golden harness: the goldens only +# prove the server is consistent with itself, this proves a client that +# knows nothing about EasyCrypt can complete the handshake, read the +# tool declarations and call a tool. +# +# NOT wired into CI, and deliberately so: it downloads +# @modelcontextprotocol/inspector through npx, so it needs node and +# network access, and it tracks a version we do not pin. Run it by hand +# after touching the protocol layer (src/ecMcp.ml). +# +# Two checks, both of which must print their result and exit 0: +# +# 1. tools/list -- the handshake and the tool table; +# 2. tools/call -- ec_load on a test fixture, whose result must +# carry the goal `1 = 1 /\ 2 = 2' and uuid 3. +# -------------------------------------------------------------------- + +set -eu + +root=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) +bin="$root/_build/default/src/ec.exe" + +while [ $# -gt 0 ]; do + case "$1" in + --bin) + [ $# -ge 2 ] || { echo "mcp-inspector-check: --bin needs an argument" >&2; exit 2; } + bin=$2; shift 2 ;; + --bin=*) + bin=${1#--bin=}; shift ;; + -h|--help) + echo "usage: mcp-inspector-check [--bin PATH]"; exit 0 ;; + *) + echo "mcp-inspector-check: unknown option: $1" >&2; exit 2 ;; + esac +done + +case "$bin" in + /*) ;; + *) bin=$(CDPATH= cd -- "$(dirname -- "$bin")" && pwd)/$(basename -- "$bin") ;; +esac + +if [ ! -x "$bin" ]; then + echo "mcp-inspector-check: no such executable: $bin" >&2 + exit 2 +fi + +if ! command -v npx > /dev/null 2>&1; then + echo "mcp-inspector-check: npx not found; install node, or skip" >&2 + exit 2 +fi + +inspector="npx --yes @modelcontextprotocol/inspector --cli" +fixture="$root/tests/llm/fixtures/simple.ec" + +echo "== tools/list ==============================================" +$inspector "$bin" mcp --method tools/list + +echo +echo "== tools/call ec_load ======================================" +$inspector "$bin" mcp \ + --method tools/call \ + --tool-name ec_load \ + --tool-arg "file=$fixture" \ + --tool-arg line=6 diff --git a/scripts/testing/mcp-parity b/scripts/testing/mcp-parity new file mode 100755 index 000000000..4ccf015e8 --- /dev/null +++ b/scripts/testing/mcp-parity @@ -0,0 +1,215 @@ +#! /usr/bin/env python3 + +# -------------------------------------------------------------------- +# Parity check: `easycrypt llm' and `easycrypt mcp' are two front-ends +# over one core, so the same operation must produce the same answer on +# both wires. +# +# mcp-parity [--bin PATH] [-v] +# +# One representative operation per tool family is played, in order, +# against two sessions -- a REPL one driven with `llm -eval', an MCP +# one driven with a JSON-RPC script -- started from the same working +# directory on the same fixture. For each step the checker asserts: +# +# * the engine uuid matches: the REPL's `[uuid:N]' envelope tag +# against the MCP result's structuredContent.uuid; +# * the payload matches: the REPL's reply body (what it prints +# between the OK/ERROR line and `') against the MCP result's +# content[0].text. +# +# The payload comparison is up to one trailing newline, which the REPL +# appends to a body that lacks one so that `' starts a line of its +# own. That is the only licensed difference; see tests/mcp/README.md +# for the two structural asymmetries this check deliberately does not +# span (envelope tags, and notices on failures). +# -------------------------------------------------------------------- + +import json +import os +import subprocess +import sys + +# -------------------------------------------------------------------- +# The operations, as (label, REPL line, MCP tool name, MCP arguments). +# One per tool family, plus a failing phrase, played in this order +# against both sessions. + +STEPS = [ + ("load", 'LOAD "fixtures/simple.ec" 6', + "ec_load", {"file": "fixtures/simple.ec", "line": 6}), + ("step", 'split.', + "ec_step", {"phrase": "split."}), + ("goals", 'GOALS ALL', + "ec_goals", {"all": True}), + ("tree", 'TREE', + "ec_tree", {}), + ("focus", 'FOCUS 2', + "ec_focus", {"path": "2"}), + ("undo", 'UNDO', + "ec_undo", {}), + ("checkpoint", 'CHECKPOINT c0', + "ec_checkpoint", {"name": "c0"}), + ("step/2", 'trivial.', + "ec_step", {"phrase": "trivial."}), + ("revert", 'REVERT c0', + "ec_revert", {"target": "c0"}), + ("search", 'SEARCH (b2i _)', + "ec_search", {"pattern": "(b2i _)"}), + ("commit", 'COMMIT', + "ec_commit", {}), + ("failure", 'apply nosuchlemma.', + "ec_step", {"phrase": "apply nosuchlemma."}), + # Strict mode, which is a session setting rather than an operation + # on the proof: turning it on and failing again stops the session, + # the phrase after that is refused, and both wires have to say the + # same thing at each of those four points. `STRICT OFF' puts the + # session back before the loads below, which reset it anyway. + ("strict/on", 'STRICT ON', + "ec_strict", {"on": True}), + ("strict/stop", 'apply nosuchlemma.', + "ec_step", {"phrase": "apply nosuchlemma."}), + ("strict/refused", 'trivial.', + "ec_step", {"phrase": "trivial."}), + ("resume", 'RESUME', + "ec_resume", {}), + ("strict/off", 'STRICT OFF', + "ec_strict", {"on": False}), + # The two load options that change what the engine does. Both + # reset the session, so they come last. `trace' is the one whose + # reply body the core builds itself, rather than handing back the + # goals -- the most front-end-independent body there is, and the + # one most worth pinning across both wires. + ("load/nosmt", 'LOAD "fixtures/simple.ec" 6 -nosmt', + "ec_load", {"file": "fixtures/simple.ec", + "line": 6, "nosmt": True}), + ("load/trace", 'LOAD "fixtures/midproof.ec" -trace', + "ec_load", {"file": "fixtures/midproof.ec", + "trace": True}), +] + + +# -------------------------------------------------------------------- +def repl_replies(binary, cwd): + """Run the REPL script and return one (uuid, body) per reply. + + The REPL wire is a sequence of blocks, each opened by an + `OK [uuid:N]' or `ERROR [uuid:N]' line and closed by a lone + `'. The opening READY block is dropped.""" + + script = "\n".join(line for (_, line, _, _) in STEPS) + out = subprocess.run( + [binary, "llm", "-eval", script], + cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + ).stdout.decode() + + replies, head, body = [], None, [] + for line in out.split("\n")[:-1]: + if head is None: + head = line + elif line == "": + replies.append((head, "".join(l + "\n" for l in body))) + head, body = None, [] + else: + body.append(line) + + def uuid_of(head): + return int(head.split("[uuid:")[1].split("]")[0]) + + return [(uuid_of(h), b) for (h, b) in replies][1:] + + +# -------------------------------------------------------------------- +def mcp_results(binary, cwd): + """Run the MCP script and return one (uuid, text) per tools/call.""" + + script = "".join( + json.dumps({ + "jsonrpc": "2.0", "id": i + 1, "method": "tools/call", + "params": {"name": tool, "arguments": args}, + }) + "\n" + for (i, (_, _, tool, args)) in enumerate(STEPS) + ) + out = subprocess.run( + [binary, "mcp"], input=script.encode(), + cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, + ).stdout.decode() + + results = [] + for line in out.splitlines(): + result = json.loads(line)["result"] + results.append((result["structuredContent"]["uuid"], + result["content"][0]["text"])) + return results + + +# -------------------------------------------------------------------- +def main(): + root = os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__)))) + binary = os.path.join(root, "_build", "default", "src", "ec.exe") + verbose = False + + args = sys.argv[1:] + while args: + if args[0] == "--bin": + binary, args = args[1], args[2:] + elif args[0].startswith("--bin="): + binary, args = args[0][len("--bin="):], args[1:] + elif args[0] in ("-v", "--verbose"): + verbose, args = True, args[1:] + elif args[0] in ("-h", "--help"): + print("usage: mcp-parity [--bin PATH] [-v]") + return 0 + else: + print(f"mcp-parity: unknown option: {args[0]}", file=sys.stderr) + return 2 + + binary = os.path.abspath(binary) + if not os.access(binary, os.X_OK): + print(f"mcp-parity: no such executable: {binary}", file=sys.stderr) + return 2 + + # Both sessions run from tests/llm, so they name the fixture the + # same way and no path difference can leak into a reply. + cwd = os.path.join(root, "tests", "llm") + + repl = repl_replies(binary, cwd) + mcp = mcp_results(binary, cwd) + + nfail = 0 + + if len(repl) != len(STEPS) or len(mcp) != len(STEPS): + print(f"FAIL (reply count: {len(STEPS)} steps, " + f"{len(repl)} REPL replies, {len(mcp)} MCP results)") + return 1 + + for ((label, line, tool, _), (ruuid, body), (muuid, text)) in \ + zip(STEPS, repl, mcp): + # The REPL ends a non-empty body with a newline so that `' + # starts a line; MCP has no sentinel and so does not. + normalized = text if text.endswith("\n") or text == "" else text + "\n" + + problems = [] + if ruuid != muuid: + problems.append(f"uuid: REPL {ruuid}, MCP {muuid}") + if normalized != body: + problems.append(f"body:\n REPL {body!r}\n MCP {normalized!r}") + + if problems: + print(f"FAIL {label} ({line!r} vs {tool})") + for problem in problems: + print(" " + problem) + nfail += 1 + else: + print(f"PASS {label} (uuid {ruuid})") + if verbose: + print("".join(" | " + l + "\n" for l in body.splitlines())) + + print("----") + print(f"{len(STEPS) - nfail} passed, {nfail} failed") + return 1 if nfail else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/testing/mcp-sessions b/scripts/testing/mcp-sessions new file mode 100755 index 000000000..8752f4063 --- /dev/null +++ b/scripts/testing/mcp-sessions @@ -0,0 +1,292 @@ +#! /usr/bin/env python3 + +# -------------------------------------------------------------------- +# Check of the session multiplexer, `easycrypt mcp -sessions'. +# +# mcp-sessions [--bin PATH] [-v] +# +# The multiplexer serves requests concurrently and answers them in +# whatever order the children finish, so its output is not a byte +# stream the golden harness could freeze. This checker drives it as a +# client would -- one reader thread, replies matched by id -- and +# asserts the contract of doc/llm/CLAUDE.md, "Multi-agent sessions": +# +# * `initialize' names the same server as the single engine; +# * `tools/list' is the engine's table with `session' required on +# every tool, plus ec_sessions and ec_close; +# * two sessions load different files at the same time and neither +# sees the other's state; +# * a call without `session', or with a name that is not one, is a +# tool-level error, not a protocol error; +# * the engine's own protocol errors come back as such; +# * ec_sessions lists the live children; ec_close kills one and +# leaves the other; a session whose engine exited is reported dead +# and restarted by the next call; +# * on end of input the multiplexer exits 0 and no child survives; +# * on SIGTERM, the same. +# +# The single-engine server is exercised by mcp-golden; nothing here +# depends on the content of a reply beyond what the multiplexer adds. +# -------------------------------------------------------------------- + +import json +import os +import signal +import subprocess +import sys +import tempfile +import threading +import time + + +# -------------------------------------------------------------------- +class Client: + """One multiplexer process, driven over stdio.""" + + def __init__(self, binary, cwd, logdir): + self.proc = subprocess.Popen( + [binary, "mcp", "-sessions", "-logdir", logdir], + cwd=cwd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, bufsize=0) + self.replies = {} + self.next_id = 0 + self.lock = threading.Lock() + threading.Thread(target=self._reader, daemon=True).start() + + def _reader(self): + for raw in self.proc.stdout: + msg = json.loads(raw) + with self.lock: + self.replies[msg.get("id")] = msg + + def send(self, method, params=None): + self.next_id += 1 + msg = {"jsonrpc": "2.0", "id": self.next_id, "method": method} + if params is not None: + msg["params"] = params + self.proc.stdin.write((json.dumps(msg) + "\n").encode()) + self.proc.stdin.flush() + return self.next_id + + def wait(self, i, timeout=120): + deadline = time.time() + timeout + while True: + with self.lock: + if i in self.replies: + return self.replies[i] + if time.time() > deadline: + raise SystemExit(f"mcp-sessions: timeout waiting for reply {i}") + time.sleep(0.02) + + def call(self, tool, args): + return self.send("tools/call", {"name": tool, "arguments": args}) + + def ask(self, tool, args): + return self.wait(self.call(tool, args)) + + def close_stdin(self): + self.proc.stdin.close() + + +def text_of(reply): + return reply["result"]["content"][0]["text"] + + +def pids_of(listing): + return [int(line.split()[2]) for line in listing.splitlines() + if line.split()[:1] and line.split()[1] == "pid"] + + +def alive(pid): + try: + os.kill(pid, 0) + return True + except OSError: + return False + + +def gone(pids, timeout=5.0): + deadline = time.time() + timeout + while time.time() < deadline: + if not any(alive(p) for p in pids): + return True + time.sleep(0.05) + return False + + +# -------------------------------------------------------------------- +def main(): + root = os.path.dirname(os.path.dirname(os.path.dirname( + os.path.abspath(__file__)))) + binary = os.path.join(root, "_build", "default", "src", "ec.exe") + verbose = False + + args = sys.argv[1:] + while args: + if args[0] == "--bin": + binary, args = args[1], args[2:] + elif args[0].startswith("--bin="): + binary, args = args[0][len("--bin="):], args[1:] + elif args[0] in ("-v", "--verbose"): + verbose, args = True, args[1:] + elif args[0] in ("-h", "--help"): + print("usage: mcp-sessions [--bin PATH] [-v]") + return 0 + else: + print(f"mcp-sessions: unknown option: {args[0]}", file=sys.stderr) + return 2 + + binary = os.path.abspath(binary) + if not os.access(binary, os.X_OK): + print(f"mcp-sessions: no such executable: {binary}", file=sys.stderr) + return 2 + + cwd = os.path.join(root, "tests", "llm") + nfail = 0 + + def check(label, ok, detail=""): + nonlocal nfail + if ok: + print(f"PASS {label}" + (f" ({detail})" if verbose and detail else "")) + else: + print(f"FAIL {label}" + (f" ({detail})" if detail else "")) + nfail += 1 + + with tempfile.TemporaryDirectory(prefix="mcp-sessions.") as logdir: + c = Client(binary, cwd, logdir) + + # -- handshake + r = c.wait(c.send("initialize", { + "protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "mcp-sessions", "version": "0"}})) + info = r["result"]["serverInfo"] + check("initialize", info["name"] == "easycrypt" + and r["result"]["protocolVersion"] == "2025-06-18", + json.dumps(r["result"])) + c.send("notifications/initialized") + + # -- the tool table + tools = {t["name"]: t for t in c.wait(c.send("tools/list"))["result"]["tools"]} + engine_tools = [n for n in tools if n not in ("ec_sessions", "ec_close")] + check("tools/list: multiplexer tools present", + "ec_sessions" in tools and "ec_close" in tools, ", ".join(tools)) + check("tools/list: session required everywhere", + all("session" in tools[n]["inputSchema"]["properties"] + and tools[n]["inputSchema"]["required"][-1] == "session" + and tools[n]["description"].endswith("sessions are independent.]") + for n in engine_tools), + f"{len(engine_tools)} engine tools") + check("tools/list: ec_close takes session", + tools["ec_close"]["inputSchema"]["required"] == ["session"]) + + # -- two sessions at once, on different files + t0 = time.time() + a = c.call("ec_load", {"session": "A", "file": "fixtures/simple.ec", "line": 6}) + b = c.call("ec_load", {"session": "B", "file": "fixtures/midproof.ec"}) + ra, rb = c.wait(a), c.wait(b) + check("parallel ec_load: both succeed", + not ra["result"]["isError"] and not rb["result"]["isError"], + f"{time.time() - t0:.1f}s") + check("parallel ec_load: independent states", + text_of(ra) != text_of(rb) + and "remaining: 2" in text_of(rb) and "remaining" not in text_of(ra)) + + # -- a step in one does not move the other + ua = c.ask("ec_step", {"session": "A", "phrase": "split."})["result"]["structuredContent"] + ub = c.ask("ec_goals", {"session": "B"})["result"]["structuredContent"] + check("ec_step in A leaves B alone", + ua["changed"] and not ub["changed"] + and "remaining: 2" in ub["text"] and "1 = 1" in ua["text"]) + + # -- errors of the multiplexer's own: tool-level + r = c.ask("ec_step", {"phrase": "split."}) + check("missing session is a tool error", + r["result"].get("isError") is True + and text_of(r).startswith("missing `session'"), text_of(r)) + r = c.ask("ec_step", {"session": "../x", "phrase": "split."}) + check("bad session name is a tool error", + r["result"].get("isError") is True + and text_of(r).startswith("invalid session name"), text_of(r)) + + # -- errors of the engine: protocol-level, under our id + r = c.ask("ec_nope", {"session": "A"}) + check("unknown tool is -32602 from the engine", + r.get("error", {}).get("code") == -32602, json.dumps(r)) + r = c.ask("ec_step", {"session": "A"}) + check("missing argument is -32602 from the engine", + r.get("error", {}).get("code") == -32602 + and "phrase" in r["error"]["message"], json.dumps(r)) + + # -- ec_sessions / ec_close + listing = text_of(c.ask("ec_sessions", {})) + pids = pids_of(listing) + names = [line.split()[0] for line in listing.splitlines()] + check("ec_sessions lists A and B", names == ["A", "B"] and len(pids) == 2 + and all(alive(p) for p in pids), listing.replace("\n", " | ")) + + check("ec_close B", text_of(c.ask("ec_close", {"session": "B"})) == "closed B") + check("ec_close B again", + text_of(c.ask("ec_close", {"session": "B"})) == "no session `B'") + listing = text_of(c.ask("ec_sessions", {})) + check("ec_close leaves A", + [l.split()[0] for l in listing.splitlines()] == ["A"] + and alive(pids[0]) and gone([pids[1]]), + listing) + r = c.ask("ec_goals", {"session": "A"}) + check("A still answers after closing B", + not r["result"]["isError"] and "1 = 1" in text_of(r)) + + # -- an engine that exits on its own + r = c.ask("ec_step", {"session": "A", "phrase": "exit."}) + check("exit. reaches the engine", text_of(r) == "session terminated") + # The engine exits right after answering; give it a moment. It + # stays a zombie until the multiplexer's next look at it, which + # is what ec_sessions does, so poll that rather than the pid. + deadline = time.time() + 5 + while True: + listing = text_of(c.ask("ec_sessions", {})) + if listing.endswith("(dead)") or time.time() > deadline: + break + time.sleep(0.1) + check("exited engine is reported dead", listing.endswith("(dead)"), listing) + r = c.ask("ec_goals", {"session": "A"}) + check("next call restarts the engine", + not r["result"]["isError"] and text_of(r).startswith("No active proof"), + text_of(r).strip()) + pids += pids_of(text_of(c.ask("ec_sessions", {}))) + + # -- ping, and shutdown on EOF + check("ping", c.wait(c.send("ping"))["result"] == {}) + c.close_stdin() + try: + code = c.proc.wait(timeout=10) + except subprocess.TimeoutExpired: + code = None + check("EOF: multiplexer exits 0", code == 0, f"exit {code}") + check("EOF: no child survives", gone(pids), str([p for p in pids if alive(p)])) + + # -- shutdown on SIGTERM + c = Client(binary, cwd, logdir) + c.ask("ec_load", {"session": "T1", "file": "fixtures/simple.ec", "line": 6}) + c.ask("ec_load", {"session": "T2", "file": "fixtures/simple.ec", "line": 6}) + pids = pids_of(text_of(c.ask("ec_sessions", {}))) + c.proc.send_signal(signal.SIGTERM) + try: + code = c.proc.wait(timeout=10) + except subprocess.TimeoutExpired: + code = None + check("SIGTERM: multiplexer exits", code is not None, f"exit {code}") + check("SIGTERM: no child survives", gone(pids), str([p for p in pids if alive(p)])) + + logs = sorted(os.listdir(logdir)) + check("one log per session", + logs == ["ec-mcp-A.log", "ec-mcp-B.log", "ec-mcp-T1.log", "ec-mcp-T2.log"], + ", ".join(logs)) + + print("----") + print(f"{'all' if nfail == 0 else nfail} {'passed' if nfail == 0 else 'failed'}") + return 1 if nfail else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/dune b/src/dune index a75a40669..155dd2e31 100644 --- a/src/dune +++ b/src/dune @@ -16,7 +16,7 @@ (public_name easycrypt.ecLib) (foreign_stubs (language c) (names eunix)) (modules :standard \ ec) - (libraries batteries camlp-streams dune-build-info dune-site inifiles lospecs markdown markdown.html pcre2 tyxml why3 yojson zarith) + (libraries batteries camlp-streams dune-build-info dune-site inifiles lospecs markdown markdown.html pcre2 threads.posix tyxml why3 yojson zarith) ) (executable diff --git a/src/ec.ml b/src/ec.ml index f3bc3467b..1c92aa99b 100644 --- a/src/ec.ml +++ b/src/ec.ml @@ -158,7 +158,7 @@ let main () = let (module Sites) = EcRelocate.sites in (* Parse command line arguments *) - let conffiles, options = + let conffiles, projini, options = let sysfile = let xdgini = XDG.Config.file @@ -220,6 +220,19 @@ let main () = exit 1 in + (* The [easycrypt.project] context of a file (walking up from the + file's directory; from the cwd when no file is given). Also used + by the LLM REPL to reconfigure per loaded file. *) + let projini (path : string option) = + Option.bind (projfile path) (fun conffile -> + Option.map + (fun ini -> { + inic_ini = ini; + inic_root = Some (Filename.dirname conffile); + }) + (read_ini_file conffile) + ) in + let getini (path : string option) = let inisys = List.filter_map @@ -230,20 +243,9 @@ let main () = conffiles in - let iniproj = - Option.bind (projfile path) (fun conffile -> - Option.map - (fun ini -> { - inic_ini = ini; - inic_root = Some (Filename.dirname conffile); - }) - (read_ini_file conffile) - ) - in - - List.ocons iniproj inisys in + List.ocons (projini path) inisys in - (conffiles, EcOptions.parse_cmdline ~ini:getini Sys.argv) in + (conffiles, projini, EcOptions.parse_cmdline ~ini:getini Sys.argv) in (* Execution of eager commands *) begin @@ -328,6 +330,11 @@ let main () = ["-boot"] else [] in + let stdlib = + options.o_options.o_loader.ldro_stdlib + |> List.map (fun d -> ["-stdlib"; d]) + |> List.flatten in + let idirs = options.o_options.o_loader.ldro_idirs |> List.map (fun (pfx, name, rec_) -> @@ -341,7 +348,7 @@ let main () = maxjobs; timeout; cpufactor; ppwidth; provers; quorum ; pragmas ; checkall; profile; why3srv ; why3 ; - reloc ; noevict; boot ; idirs ; + reloc ; noevict; boot ; stdlib ; idirs ; ] in @@ -420,11 +427,20 @@ let main () = let ldropts = options.o_options.o_loader in begin + (* [-stdlib DIR] (repeatable) fully replaces the built-in + [Sites.theories] roots. This is stronger than [-boot], which + only skips the recursive-System add but still injects + [/prelude]. *) + let theories = + match ldropts.ldro_stdlib with + | [] -> Sites.theories + | ds -> ds + in List.iter (fun theory -> EcCommands.addidir ~namespace:`System (Filename.concat theory "prelude"); if not ldropts.ldro_boot then EcCommands.addidir ~namespace:`System ~recursive:true theory - ) Sites.theories; + ) theories; List.iter (fun (onm, name, isrec) -> EcCommands.addidir ?namespace:(omap (fun nm -> `Named nm) onm) @@ -449,7 +465,6 @@ let main () = (*---*) gccompact : int option; (*---*) docgen : bool; (*---*) outdirp : string option; - (*---*) upto : (int * int option) option; mutable trace : trace1 list option; } @@ -528,7 +543,6 @@ let main () = ; gccompact = None ; docgen = false ; outdirp = None - ; upto = None ; trace = None } end @@ -564,39 +578,18 @@ let main () = ; gccompact = cmpopts.cmpo_compact ; docgen = false ; outdirp = None - ; upto = None ; trace = trace0 } end - | `Llm llmopts -> begin - let name = llmopts.llmo_input in - - begin try - let ext = Filename.extension name in - ignore (EcLoader.getkind ext : EcLoader.kind) - with EcLoader.BadExtension ext -> - Format.eprintf "do not know what to do with %s@." ext; - exit 1 - end; - - let lastgoals = llmopts.llmo_lastgoals in - let terminal = - lazy (T.from_channel ~name ~progress:`Silent ~lastgoals (open_in name)) - in + | `Llm llmopts -> + EcLlm.run ~relocdir ~boot:ldropts.ldro_boot ~projini llmopts - { prvopts = llmopts.llmo_provers - ; input = Some name - ; terminal = terminal - ; interactive = false - ; eco = true - ; gccompact = None - ; docgen = false - ; outdirp = None - ; upto = llmopts.llmo_upto - ; trace = None } + | `Mcp mcpopts when mcpopts.mcpo_sessions -> + EcMcpMux.run mcpopts - end + | `Mcp mcpopts -> + EcMcp.run ~relocdir ~boot:ldropts.ldro_boot ~projini mcpopts | `Runtest _ -> (* Eagerly executed *) @@ -638,7 +631,6 @@ let main () = ; gccompact = None ; docgen = true ; outdirp = docopts.doco_outdirp - ; upto = None ; trace = None } end @@ -658,7 +650,7 @@ let main () = EcCommands.set_current_path current_path); (* Check if the .eco is up-to-date and exit if so *) - (if not state.docgen && state.upto = None then + (if not state.docgen then oiter (fun input -> if EcCommands.check_eco input then exit 0) state.input); @@ -745,16 +737,6 @@ let main () = (* Warn about GC-regressed OCaml versions (5.0-5.3) *) warn_ocaml_version terminal; - (* Check if a location is past the -upto point *) - let past_upto (loc : EcLocation.t) = - match state.upto with - | None -> false - | Some (line, col) -> - let (sl, sc) = loc.loc_start in - sl > line || (sl = line && match col with - | None -> true - | Some c -> sc >= c) in - try if T.interactive terminal then Sys.catch_break true; @@ -824,13 +806,6 @@ let main () = (fun p -> let loc = p.EP.gl_action.EcLocation.pl_loc in - (* -upto: if this command starts past the target, print goals and exit *) - if past_upto loc then begin - T.finalize terminal; - EcCommands.pp_current_goal_or_noproof ~all:true Format.std_formatter; - exit 0 - end; - let timed = p.EP.gl_debug = Some `Timed in let break = p.EP.gl_debug = Some `Break in let ignore_fail = ref false in diff --git a/src/ecCommands.ml b/src/ecCommands.ml index 1260919e2..c2125f7b5 100644 --- a/src/ecCommands.ml +++ b/src/ecCommands.ml @@ -139,6 +139,7 @@ module Loader : sig val addidir : ?namespace:namespace -> ?recursive:bool -> string -> loader -> unit val aslist : loader -> ((namespace option * string) * idx_t) list + val setidirs : ((namespace option * string) * idx_t) list -> loader -> unit val locate : ?namespaces:namespace option list -> string -> loader -> (namespace option * string * kind) option @@ -199,6 +200,9 @@ end = struct let aslist (ld : loader) = EcLoader.aslist ld.ld_core + let setidirs (idirs : ((namespace option * string) * idx_t) list) (ld : loader) = + EcLoader.setidirs idirs ld.ld_core + let locate ?namespaces (path : string) (ld : loader) = EcLoader.locate ?namespaces path ld.ld_core @@ -228,6 +232,122 @@ end (* -------------------------------------------------------------------- *) type loader = Loader.loader +(* -------------------------------------------------------------------- *) +(* Elaborated theories, kept across the scope rebuilds a reload does. + + [EcScope] already declines to read a theory twice: [Theory.require] + consults the scope's [sc_loaded] before it runs a loader. But that + table is part of the scope, and a front-end that reloads a file by + rebuilding the scope from nothing -- the LLM REPL's LOAD, [pragma + restart.] -- starts from an empty one and re-reads every theory the + file requires. On a development of any size that *is* the reload: on + the goldbach sources, [require import Goldbach.] alone is 49s where + the file's own 470 lines are milliseconds, and none of it is proofs + ([require] already reads with checking off, which is the same + mechanism LOAD -noproof borrows). + + So the theories are kept here as well, outside the scope, and a + rebuilt scope is seeded with the ones the sources still describe. + Still describe is decided by digest, transitively: a theory is + served from here only if the file it was read from digests to what + it did then, and if every theory it required is served too -- an + edit to a file five requires down invalidates everything above it, + which is the whole point of checking the closure rather than the + file. The load path is the other half of the key, since under a + different one the same name may name a different file; rather than + work out which names moved, a reload that starts from a different + load path drops the table whole. + + Off unless a front-end asks for it. The batch compiler reads each + file once, in a process of its own, so it has nothing to gain here + and no reason to carry the risk of an entry that outlives its + source. *) +module ThCache : sig + val enable : unit -> unit + + (* Take the theory [ri] names out of [scope], which must be the scope + [Theory.require] returned for it, and file it under [file]. *) + val record : file:string -> EcScope.required_info -> EcScope.scope -> unit + + (* Seed a freshly built scope with the entries that are still good + under [loadpath]. Both stamps are taken at the same point of a + reload, so they compare. *) + val seed : + loadpath:((Loader.namespace option * string) * Loader.idx_t) list + -> EcScope.scope -> EcScope.scope +end = struct + type entry = { + ce_file : string; (* the file the theory was read from *) + ce_digest : Digest.t; (* ... and its digest, as read *) + ce_deps : EcScope.required; (* the theories reading it required *) + ce_th : EcScope.thloaded; + } + + let enabled : bool ref = ref false + + let table : (EcSymbols.symbol, entry) Hashtbl.t = Hashtbl.create 97 + + let stamp : + (((Loader.namespace option * string) * Loader.idx_t) list) option ref = + ref None + + let enable () = enabled := true + + let record ~(file : string) (ri : EcScope.required_info) scope = + if !enabled then + EcScope.Theory.loaded scope ri.EcScope.rqd_name + |> oiter (fun (th, deps) -> + Hashtbl.replace table ri.EcScope.rqd_name + { ce_file = file; + ce_digest = ri.EcScope.rqd_digest; + ce_deps = deps; + ce_th = th; }) + + (* Drop the entries the sources have moved out from under, and return + the names of those left. The recursion is memoized, and answers + [false] for a name it is still deciding: requires are acyclic + ([process_th_require1] refuses a cycle), and a cycle that got in + all the same must not be served. *) + let prune () = + let verdict : (EcSymbols.symbol, bool) Hashtbl.t = Hashtbl.create 97 in + + let rec live (name : EcSymbols.symbol) = + match Hashtbl.find_opt verdict name with + | Some b -> b + | None -> + Hashtbl.replace verdict name false; + let b = + match Hashtbl.find_opt table name with + | None -> false + | Some e -> + (try Digest.file e.ce_file = e.ce_digest + with Sys_error _ -> false) + && List.for_all + (fun (d : EcScope.required_info) -> live d.EcScope.rqd_name) + e.ce_deps + in Hashtbl.replace verdict name b; b + in + + let names = Hashtbl.fold (fun name _ acc -> name :: acc) table [] in + let (keep, drop) = List.partition live names in + List.iter (Hashtbl.remove table) drop; + keep + + let seed ~loadpath scope = + if not !enabled then scope else begin + if !stamp <> Some loadpath then Hashtbl.reset table; + stamp := Some loadpath; + EcScope.Theory.seed_loaded scope + (List.map + (fun name -> + let e = Hashtbl.find table name in + (name, (e.ce_th, e.ce_deps))) + (prune ())) + end +end + +let enable_theory_cache = ThCache.enable + (* -------------------------------------------------------------------- *) let process_search scope qs = EcScope.Search.search scope qs @@ -442,8 +562,24 @@ let check_opname_validity (scope : EcScope.scope) (x : string) = "operator `%s' cannot be used in infix mode" x (* -------------------------------------------------------------------- *) +(* Where [print] renders. The batch compiler and the interactive + terminals want the process's stdout, which is what this defaults to. + A front-end that frames its replies ([llm], [mcp]) cannot let the + engine write outside the frame, so it installs a formatter of its + own -- [search] and [locate] already come back through the notifier, + and this is what puts [print] on the same footing. Routing it + through the notifier instead would have been the smaller patch, but + the notifier drops `Info under the batch compiler's log level, so + `ec compile' would have stopped printing altogether. *) +let print_formatter = ref Format.std_formatter + +let set_print_formatter (fmt : Format.formatter) = + print_formatter := fmt + let process_print scope p = - process_pr Format.std_formatter scope p + let fmt = !print_formatter in + process_pr fmt scope p; + Format.pp_print_flush fmt () (* -------------------------------------------------------------------- *) let process_expect scope (expected, p) = @@ -638,6 +774,7 @@ and process_th_require1 ld scope (nm, (sysname, thname), io) = in let scope = EcScope.Theory.require scope (name, kind) loader in + ThCache.record ~file:filename name scope; match io with | None -> scope | Some `Export -> EcScope.Theory.export scope ([], name.EcScope.rqd_name) @@ -916,6 +1053,21 @@ let addidir ?namespace ?recursive (idir : string) = let loadpath () = List.map fst (Loader.aslist loader) +(* The include path lives in this one process-global loader and only + ever grows: [addidir] never removes anything, and [initialize] -- + [~restart:true] included -- does not rebuild it. A front-end that + loads unrelated files one after another therefore needs a way back, + or each loaded file's own directory stays searchable for every later + load. The batch compiler loads one file and exits, so it never wants + this. *) +type loadpath_mark = ((Loader.namespace option * string) * Loader.idx_t) list + +let loadpath_mark () : loadpath_mark = + Loader.aslist loader + +let loadpath_reset (mark : loadpath_mark) = + Loader.setidirs mark loader + let set_current_path (path : string) = Loader.set_current_path path loader @@ -941,12 +1093,18 @@ let initial ~checkmode ~boot ~checkproof = EcScope.Prover.po_quorum = checkmode.cm_quorum; } in + (* Taken before [loader] is shadowed by its system-only view below: + the stamp the cache is keyed on is the whole include path, which + is what a reload of a file from another project changes. *) + let lpstamp = Loader.aslist loader in + let perv = (None, (mk_loc _dummy EcCoreLib.i_Pervasive, None), Some `Export) in let tactics = (None, (mk_loc _dummy "Tactics", None), Some `Export) in let prelude = (None, (mk_loc _dummy "Logic", None), Some `Export) in let loader = Loader.forsys loader in let gstate = EcGState.from_flags [("profile", profile)] in let scope = EcScope.empty gstate in + let scope = ThCache.seed ~loadpath:lpstamp scope in let scope = process_th_require1 loader scope perv in let scope = if boot then scope else List.fold_left (process_th_require1 loader) @@ -999,6 +1157,69 @@ let push_context scope context = ct_stack = context.ct_stack |> omap (fun st -> context.ct_current :: st); } +(* -------------------------------------------------------------------- *) +(* Rotate the focus of the currently active proof so that the goal at + 1-based index [k] becomes the focused one. The change is persisted + in the context with a new uuid so UNDO/REVERT can roll it back. + Returns the new number of open goals on success, or an error + message on failure. *) +let focus_goal (k : int) : (int, string) result = + match !context with + | None -> Error "no active context" + | Some ctxt -> + match EcScope.xgoal ctxt.ct_current with + | None -> Error "no active proof" + | Some puc -> + match puc.EcScope.puc_active with + | None -> Error "no active proof" + | Some (pac, pct) -> + match pac.EcScope.puc_jdg with + | EcScope.PSNoCheck -> Error "proof is in no-check mode" + | EcScope.PSCheck pf -> + let n = List.length (EcCoreGoal.all_hd_opened pf) in + if n = 0 then Error "no open goals" + else if k < 1 || k > n then + Error (Printf.sprintf + "focus: index %d out of range (1..%d)" k n) + else if k = 1 then Ok n + else begin + let pf = EcCoreGoal.rotate_focus k pf in + let pac = { pac with EcScope.puc_jdg = EcScope.PSCheck pf } in + let puc = + { puc with EcScope.puc_active = Some (pac, pct) } in + let scope = EcScope.set_xgoal ctxt.ct_current puc in + context := Some (push_context scope ctxt); + Ok n + end + +(* Disable bullet enforcement for REPL-driven phrases. Drops the global + pragma so newly-opened proofs have no bullet stack, and clears the + stack on any currently active proof so REPL phrases are not checked + against it. Idempotent. Does not advance the undo level. Returns + the stack that was in place (if any) at the moment the active + proof's bullets were first cleared; returns [None] on idempotent + calls (where the stack is already gone). Callers use the returned + stack to drive bullet-character selection in [COMMIT]. *) +let disable_repl_bullets () : EcBullets.stack option = + pragma_strict_bullets false; + match !context with + | None -> None + | Some ctxt -> + match EcScope.xgoal ctxt.ct_current with + | None -> None + | Some puc -> + match puc.EcScope.puc_active with + | None -> None + | Some (pac, pct) -> + match pac.EcScope.puc_bullets with + | None -> None + | Some _ as prior -> + let pac = { pac with EcScope.puc_bullets = None } in + let puc = { puc with EcScope.puc_active = Some (pac, pct) } in + let scope = EcScope.set_xgoal ctxt.ct_current puc in + context := Some { ctxt with ct_current = scope }; + prior + (* -------------------------------------------------------------------- *) let initialize ~restart ~undo ~boot ~checkmode ~checkproof = assert (restart || EcUtils.is_none !context); @@ -1037,6 +1258,27 @@ let apply_pragma_option (x : string) = else if n > 1 && x.[0] = '-' then setflag (String.sub x 1 (n - 1)) false else apply_pragma x +(* -------------------------------------------------------------------- *) +(* Proof checking on/off, on the *current* scope. Reading and writing it + is how LOAD skips the proofs it was asked to skip: [`Off] is the mode + a [require]d file is already read in, so the lemmas it declares are + admitted as they stand. Both the current scope and the root are + updated, so the setting survives the undo stack the way a pragma + does -- an [undo] back into the skipped region must not resurrect a + checking mode the caller has since turned off. *) +let check_mode () : EcScope.Prover.check_mode = + EcScope.Prover.get_check_mode (oget !context).ct_current + +let set_check_mode (mode : EcScope.Prover.check_mode) = + let ct = oget !context in + context := Some { ct with + ct_current = EcScope.Prover.set_check_mode ct.ct_current mode; + ct_root = EcScope.Prover.set_check_mode ct.ct_root mode; + ct_stack = + Option.map + (List.map (fun sc -> EcScope.Prover.set_check_mode sc mode)) + ct.ct_stack; } + (* -------------------------------------------------------------------- *) let uuid () : int = (oget !context).ct_level @@ -1055,6 +1297,21 @@ let undo (olduuid : int) = context := Some (pop_context (oget !context)) done +(* -------------------------------------------------------------------- *) +(* [undo] only pops, so it cannot undo an [undo]: input that lowered the + uuid before failing leaves it at the wrong state, not the one it + started from. A caller that has to put the engine back *exactly* + where it was takes a mark first. The context is an immutable record + -- current scope, undo stack, uuid -- so this is a snapshot, not a + replay: restoring it moves forward as readily as backward. *) +type undo_mark = context + +let undo_mark () : undo_mark = + oget !context + +let undo_restore (mark : undo_mark) = + context := Some mark + (* -------------------------------------------------------------------- *) let doc_comment (doc : [`Global | `Item] * string) : unit = let current = oget !context in @@ -1139,8 +1396,50 @@ let pp_current_goal ?(all = false) stream = end (* -------------------------------------------------------------------- *) +let in_proof () = + Option.is_some (S.xgoal (current ())) + +(* Return the list of open-goal handles at the top level of the active + proof, focused-first, or [] if no proof is active. *) +let open_handles () : EcCoreGoal.handle list = + match S.xgoal (current ()) with + | Some { S.puc_active = + Some ({ S.puc_jdg = S.PSCheck pf }, _) } -> + EcCoreGoal.all_hd_opened pf + | _ -> [] + +(* The proof environment of the active proof, or [None] if no proof is + active. A [proofenv] is immutable and cumulative, so a snapshot taken + while the proof was open keeps answering DAG queries after [qed] has + discarded the active proof. *) +let current_proofenv () : EcCoreGoal.proofenv option = + match S.xgoal (current ()) with + | Some { S.puc_active = + Some ({ S.puc_jdg = S.PSCheck pf }, _) } -> + Some (EcCoreGoal.proofenv_of_proof pf) + | _ -> None + +(* Direct DAG children of [h] in the active proof. [] if no proof. *) +let children_of (h : EcCoreGoal.handle) : EcCoreGoal.handle list = + match S.xgoal (current ()) with + | Some { S.puc_active = + Some ({ S.puc_jdg = S.PSCheck pf }, _) } -> + EcCoreGoal.children_of_handle + (EcCoreGoal.proofenv_of_proof pf) h + | _ -> [] + +(* Parent of [h] in the active proof's DAG, or [None] if [h] is the + root or no proof is active. *) +let parent_of (h : EcCoreGoal.handle) : EcCoreGoal.handle option = + match S.xgoal (current ()) with + | Some { S.puc_active = + Some ({ S.puc_jdg = S.PSCheck pf }, _) } -> + EcCoreGoal.parent_of_handle + (EcCoreGoal.proofenv_of_proof pf) h + | _ -> None + let pp_current_goal_or_noproof ?(all = false) stream = - if Option.is_some (S.xgoal (current ())) then + if in_proof () then pp_current_goal ~all stream else Format.fprintf stream "No active proof.@\n%!" @@ -1178,3 +1477,36 @@ let pp_all_goals () = end | _ -> [] + +(* -------------------------------------------------------------------- *) +type goal_entry = { + ge_index : int; + ge_focused : bool; + ge_text : string; +} + +(* Render the open goals of the active proof, focused first. *) +let pp_tree ?(all = false) () : goal_entry list = + let scope = current () in + match S.xgoal scope with + | Some { S.puc_active = Some ({ puc_jdg = S.PSCheck pf }, _) } -> begin + match EcCoreGoal.opened pf with + | None -> [] + | Some _ -> + let ppe = EcPrinting.PPEnv.ofenv (S.env scope) in + let goals = EcCoreGoal.all_opened pf in + List.mapi (fun i { EcCoreGoal.g_hyps; EcCoreGoal.g_concl } -> + let text = + if all then + let buf = Buffer.create 256 in + let hc = (EcEnv.LDecl.tohyps g_hyps, g_concl) in + Format.fprintf + (Format.formatter_of_buffer buf) + "%a@?" (EcPrinting.pp_goal1 ppe) hc; + Buffer.contents buf + else + Format.asprintf "%a" (EcPrinting.pp_form ppe) g_concl + in + { ge_index = i + 1; ge_focused = i = 0; ge_text = text; }) goals + end + | _ -> [] diff --git a/src/ecCommands.mli b/src/ecCommands.mli index 8a1220ae0..d0248408e 100644 --- a/src/ecCommands.mli +++ b/src/ecCommands.mli @@ -12,6 +12,27 @@ val addidir : ?namespace:EcLoader.namespace -> ?recursive:bool -> string -> unit val loadpath : unit -> (EcLoader.namespace option * string) list val set_current_path : string -> unit +(* An opaque record of the include path at one point in time. + [loadpath_reset] puts the loader back to it, dropping every + directory added since. The include path is process-global and + [addidir] only ever grows it, so this is the only way to keep one + loaded file's directory out of an unrelated later load. *) +type loadpath_mark + +val loadpath_mark : unit -> loadpath_mark +val loadpath_reset : loadpath_mark -> unit + +(* Keep the theories a [require] elaborates across the scope rebuilds + [initialize ~restart:true] does, so that reloading a file does not + re-read everything it requires -- which, on a development of any + size, is what a reload costs. An entry is reused only while the file + it came from, and every file below it, digests to what it did when + it was read; a rebuild that starts from a different include path + drops the lot. Off until this is called, and there is no way back: + the batch compiler reads each file once per process and has nothing + to gain, the interactive front-ends reload all day. *) +val enable_theory_cache : unit -> unit + (* -------------------------------------------------------------------- *) type notifier = EcGState.loglevel -> string Lazy.t -> unit @@ -39,6 +60,13 @@ val current : unit -> EcScope.scope val addnotifier : notifier -> unit val notify : EcGState.loglevel -> ('a, Format.formatter, unit, unit) format4 -> 'a +(* Redirect the [print] statement's output. It goes to the process's + stdout by default; a front-end that frames its replies installs a + formatter it can read back, so that [print] lands inside the frame + the way [search] and [locate] already do. The formatter is flushed + after every [print]. *) +val set_print_formatter : Format.formatter -> unit + (* -------------------------------------------------------------------- *) val process_internal : loader @@ -52,9 +80,29 @@ val process : ?src:string -> ?timed:bool -> ?break:bool -> val undo : int -> unit val reset : unit -> unit + +(* An opaque snapshot of the engine's undo context: current scope, undo + stack and uuid. [undo] only pops, so it cannot undo an [undo] -- + input that lowered the uuid before failing lands somewhere else + entirely. [undo_restore] puts the engine back exactly, forward as + well as backward. Pragmas and the printing state are global and + outside the context, as they already are for [undo]. *) +type undo_mark + +val undo_mark : unit -> undo_mark +val undo_restore : undo_mark -> unit val uuid : unit -> int val mode : unit -> string +(* Whether the proofs of the lemmas the engine reads from here on are + checked. [`Off] admits every lemma as an axiom -- its proof script is + skipped whole, not even typed -- which is the mode a [require]d file + is already read in; see [EcScope.Prover.check_mode]. The setting is + applied to the whole undo stack, so it behaves like a pragma rather + than like a scope the undo stack could take back. *) +val check_mode : unit -> EcScope.Prover.check_mode +val set_check_mode : EcScope.Prover.check_mode -> unit + val check_eco : string -> bool val doc_comment : [`Global | `Item] * string -> unit @@ -65,6 +113,65 @@ val pp_current_goal_or_noproof : ?all:bool -> Format.formatter -> unit val pp_maybe_current_goal : Format.formatter -> unit val pp_all_goals : unit -> string list +(* -------------------------------------------------------------------- *) +(* Proof-state introspection and navigation, for the LLM front-ends + ([EcLlmCore] and the REPL and MCP servers on top of it). Batch + compilation needs none of it: it never asks what the open goals are, + never walks between them, and never rewrites a proof's bullet state. + + [focus_goal] and [disable_repl_bullets] MUTATE the global context; + every other val here is a query that leaves it alone. *) + +(* One open subgoal, as [pp_tree] reports it. *) +type goal_entry = { + (* 1-based position in the open-goal list. *) + ge_index : int; + (* The focused goal -- always the one at index 1, EC's focus model + keeping the focused goal at the head. *) + ge_focused : bool; + (* The goal's conclusion on one line, or its full body under [~all]. *) + ge_text : string; +} + +(* Is a proof active in the current scope? *) +val in_proof : unit -> bool + +(* Every open goal of the active proof, rendered, focused first (the + order [open_handles] uses). [] when no proof is active. *) +val pp_tree : ?all:bool -> unit -> goal_entry list + +(* Handles of the active proof's open goals, focused first; [] when no + proof is active. Same goals as [pp_tree], unrendered. *) +val open_handles : unit -> EcCoreGoal.handle list + +(* The active proof's environment, the one the DAG queries below read. + It is immutable and cumulative, so a snapshot keeps answering for its + own proof after [qed] has discarded it -- which is how COMMIT still + reconstructs the structure of a finished proof. [None] when no proof + is active. *) +val current_proofenv : unit -> EcCoreGoal.proofenv option + +(* Proof-DAG navigation in the *active* proof; both answer emptily when + no proof is active. Use [EcCoreGoal.children_of_handle] / + [parent_of_handle] on a [current_proofenv] snapshot to query a proof + other than the active one. *) +val children_of : EcCoreGoal.handle -> EcCoreGoal.handle list +val parent_of : EcCoreGoal.handle -> EcCoreGoal.handle option + +(* MUTATES the context: rotates the active proof's focus onto the open + goal at 1-based index [k], and pushes the result as a new undo level, + so UNDO/REVERT roll the rotation back like any other step. Returns + the number of open goals. *) +val focus_goal : int -> (int, string) result + +(* MUTATES the context: turns bullet enforcement off for phrases typed + at a prompt, by clearing the [strict_bullets] pragma and dropping the + active proof's bullet stack. Spends no undo level. Returns the stack + it dropped -- which COMMIT reads to pick bullet tokens that do not + collide with the ones already open -- and [None] on the idempotent + later calls, the stack being gone by then. *) +val disable_repl_bullets : unit -> EcBullets.stack option + (* -------------------------------------------------------------------- *) val pragma_verbose : bool -> unit val pragma_g_prall : bool -> unit diff --git a/src/ecCoreGoal.ml b/src/ecCoreGoal.ml index 728824b4d..fc79359c9 100644 --- a/src/ecCoreGoal.ml +++ b/src/ecCoreGoal.ml @@ -132,9 +132,13 @@ type proof = { } and proofenv = { - pr_uid : ID.id; (* unique ID for this proof *) - pr_main : ID.id; (* top goal, contains the final result *) - pr_goals : goal ID.Map.t; (* set of all goals, closed and opened *) + pr_uid : ID.id; (* unique ID for this proof *) + pr_main : ID.id; (* top goal, contains the final result *) + pr_goals : goal ID.Map.t; (* set of all goals, closed and opened *) + pr_parent : handle ID.Map.t; + (* For each non-root handle, the parent in the proof DAG: i.e. + the handle that was being worked on when this one was created + via [FApi.newgoal]. The root [pr_main] is absent. *) } and pregoal = { @@ -463,17 +467,24 @@ module FApi = struct tcenv (* ------------------------------------------------------------------ *) - let pf_newgoal (pe : proofenv) ?vx hyps concl = + let pf_newgoal (pe : proofenv) ?parent ?vx hyps concl = let hid = ID.gen () in let pregoal = { g_uid = hid; g_hyps = hyps; g_concl = concl; g_simpl = EcEnv.SimplifyContext.empty; } in let goal = { g_goal = pregoal; g_validation = vx; } in - let pe = { pe with pr_goals = ID.Map.add pregoal.g_uid goal pe.pr_goals; } in + let pr_goals = ID.Map.add pregoal.g_uid goal pe.pr_goals in + let pr_parent = + match parent with + | None -> pe.pr_parent + | Some p -> ID.Map.add pregoal.g_uid p pe.pr_parent + in + let pe = { pe with pr_goals; pr_parent } in (pe, pregoal) (* ------------------------------------------------------------------ *) let newgoal (tc : tcenv) ?(hyps : LDecl.hyps option) (concl : form) = let hyps = ofdfl (fun () -> tc_hyps tc) hyps in - let (pe, pg) = pf_newgoal (tc_penv tc) hyps concl in + let parent = tc.tce_tcenv.tce_goal |> Option.map (fun g -> g.g_uid) in + let (pe, pg) = pf_newgoal (tc_penv tc) ?parent hyps concl in let pg = { pg with g_simpl = tc1_simplify_context tc.tce_tcenv } in let pe = update_goal_map (fun g -> { g with g_goal = pg }) pg.g_uid pe in @@ -1006,9 +1017,10 @@ let start (hyps : LDecl.hyps) (goal : form) = let goal = { g_uid = hid; g_hyps = hyps; g_concl = goal; g_simpl = EcEnv.SimplifyContext.empty; } in let goal = { g_goal = goal; g_validation = None; } in - let env = { pr_uid = uid; - pr_main = hid; - pr_goals = ID.Map.singleton hid goal; } in + let env = { pr_uid = uid; + pr_main = hid; + pr_goals = ID.Map.singleton hid goal; + pr_parent = ID.Map.empty; } in { pr_env = env; pr_opened = [hid]; } @@ -1030,6 +1042,33 @@ let all_opened (pf : proof) = (* -------------------------------------------------------------------- *) let closed (pf : proof) = List.is_empty pf.pr_opened +(* -------------------------------------------------------------------- *) +(* Direct children of [h] in the proof DAG, in creation order. This is + driven by [pr_parent], the explicit parent edge recorded by + [FApi.newgoal] at the moment each child handle is allocated. The + iteration order matches creation order because handles are + generated by a monotonic counter and [ID.Map] iterates by key. *) +let children_of_handle (pe : proofenv) (h : handle) : handle list = + ID.Map.fold + (fun child parent acc -> + if eq_handle parent h then child :: acc else acc) + pe.pr_parent [] + |> List.rev + +(* Parent of [h] in the proof DAG, or [None] if [h] is the root. *) +let parent_of_handle (pe : proofenv) (h : handle) : handle option = + ID.Map.find_opt h pe.pr_parent + +(* -------------------------------------------------------------------- *) +let rotate_focus (k : int) (pf : proof) = + let n = List.length pf.pr_opened in + if k < 1 || k > n then + invalid_arg "EcCoreGoal.rotate_focus"; + if k = 1 then pf + else + let pre, post = List.split_at (k - 1) pf.pr_opened in + { pf with pr_opened = post @ pre } + (* -------------------------------------------------------------------- *) module Exn = struct let recast pe _hyps f x = diff --git a/src/ecCoreGoal.mli b/src/ecCoreGoal.mli index 2f1b51740..19d0feb09 100644 --- a/src/ecCoreGoal.mli +++ b/src/ecCoreGoal.mli @@ -207,6 +207,18 @@ val all_opened : proof -> pregoal list (* Check if a proof is done *) val closed : proof -> bool +(* Direct children of [h] in the proof DAG, in creation order. *) +val children_of_handle : proofenv -> handle -> handle list + +(* Parent of [h] in the proof DAG, or [None] if [h] is the root. *) +val parent_of_handle : proofenv -> handle -> handle option + +(* Rotate the list of opened goals at the top level. [rotate_focus k pf] + makes the goal currently at 1-based index [k] the new focused goal, + preserving the cyclic order of the others. Raises [Invalid_argument] + if [k] is out of range. *) +val rotate_focus : int -> proof -> proof + (* -------------------------------------------------------------------- *) val tc_error : proofenv -> ?catchable:bool -> ?loc:EcLocation.t -> ?who:string diff --git a/src/ecLlm.ml b/src/ecLlm.ml new file mode 100644 index 000000000..3620d3751 --- /dev/null +++ b/src/ecLlm.ml @@ -0,0 +1,440 @@ +(* -------------------------------------------------------------------- *) +(* The LLM coding-agent REPL. See [ecLlm.mli] for the entry point. + + This is the text front-end only: line parsing, the OK/ERROR/ + envelope, the multi-line block buffer, QUIET, HELP and the -eval + driver. Everything engine-facing lives in [EcLlmCore], which the + MCP front-end shares. *) + +open EcUtils + +(* -------------------------------------------------------------------- *) +(* Path to the bundled LLM-agent guide. *) +let llm_guide_path () = + let (module Sites) = EcRelocate.sites in + match EcRelocate.sourceroot with + | Some root -> + Filename.concat (Filename.concat root "doc/llm") "CLAUDE.md" + | None -> + Filename.concat Sites.doc "llm-guide.md" + +(* Print the bundled guide to stdout. Used by [-help]. *) +let print_llm_guide () = + let path = llm_guide_path () in + try + let ic = open_in path in + begin try while true do + print_char (input_char ic) + done with End_of_file -> () end; + close_in ic + with Sys_error e -> + Printf.eprintf "cannot read LLM guide: %s\n%!" e + +(* -------------------------------------------------------------------- *) +(* Body escaping. + + A reply is framed by a status line and a lone [] sentinel, and + nothing downstream of us knows that: a body line that is itself + envelope-shaped closes the frame early and desynchronizes the + client. HELP does it to itself -- doc/llm/CLAUDE.md quotes the + protocol it documents, [] lines included -- and any goal, + notice or error text carrying such a line would do the same. + + So every body line that is envelope-shaped goes out with one extra + leading space. Leading spaces are part of the test, so escaping an + already-escaped line escapes it again, and the rule is reversible: a + client that sees an envelope-shaped body line drops one leading + space from it, and leaves every other line alone. The rule is + documented in doc/llm/CLAUDE.md and tests/llm/README.md. + + The MCP front-end needs none of this: its frame is a JSON string. *) +let envelope_shaped (line : string) = + let n = String.length line in + let rec skip i = if i < n && line.[i] = ' ' then skip (i + 1) else i in + let body = String.sub line (skip 0) (n - skip 0) in + body = "" + || List.exists + (fun kw -> String.starts_with body (kw ^ " [uuid:")) + ["OK"; "ERROR"; "READY"] + +(* -------------------------------------------------------------------- *) +(* Surface command vocabulary. Parsing turns each stdin line into one + of these, and dispatch is a flat pattern-match. Argument + parsing/validation lives here; commands that interact with mutable + session state (checkpoints table) carry only the raw user-supplied + data and let [EcLlmCore] do the lookup. *) +module Parse = struct + type command = + | Quit + | Help + | Undo + | Goals of [`One | `All] + | Tree of [`One | `All] + | Commit + | Focus of int list (* dotted path; [k] = "FOCUS k" *) + | Next + | Checkpoint of string + | Revert of string (* uuid-or-name; the core resolves *) + | Quiet of bool + | Strict of bool + | Resume + | Search of string (* trailing "." already stripped *) + | Load of load (* parsed LOAD arguments *) + | Ec of string (* fall-through: raw EasyCrypt input *) + | Begin_multi + | Done_multi + | Multi_line of string + | Blank + + and load = { + ld_file : string; + ld_upto : (int * int option) option; + ld_nosmt : bool; + ld_noproof : bool; + ld_trace : bool; + } + + exception Parse_error of string + + (* Match [kw] as a prefix: succeeds on exactly [kw] (no argument) + or [kw ^ " " ^ ...] (with argument), returning the stripped + argument tail. Returns [None] otherwise. This recognises both + "CHECKPOINT" and "CHECKPOINT foo" the same way, so we can + diagnose the missing-name case ourselves instead of falling + through to EC's parser. *) + let keyword_arg kw line = + if line = kw then Some "" + else if String.starts_with line (kw ^ " ") then + let n = String.length kw + 1 in + Some (String.strip + (String.sub line n (String.length line - n))) + else None + + let parse_focus arg = + if arg = "" then + raise (Parse_error "FOCUS: missing argument"); + match EcLlmCore.parse_goal_path ~what:"FOCUS" arg with + | Ok path -> Focus path + | Error msg -> raise (Parse_error msg) + + let parse_checkpoint name = + if name = "" then + raise (Parse_error "CHECKPOINT: missing name"); + Checkpoint name + + let parse_revert spec = + if spec = "" then + raise (Parse_error + "REVERT: missing uuid or checkpoint name"); + Revert spec + + let parse_search query = + if query = "" then + raise (Parse_error "SEARCH: missing query"); + let query = + if String.ends_with query "." + then String.sub query 0 (String.length query - 1) + else query + in + Search query + + (* LOAD "file.ec" [LINE[:COL]] [-nosmt] [-noproof] [-trace]. + Argument errors are signalled with [failwith] and turned into + [Parse_error] below, so they reach the wire exactly as any other + line-parse error does (including the bare "int_of_string" of a + malformed LINE:COL). *) + let parse_load args = + try + let args = String.strip args in + if args = "" then failwith "LOAD: missing filename"; + (* Parse quoted or unquoted filename. *) + let filename, rest = + if args.[0] = '"' then + let close = + try String.index_from args 1 '"' + with Not_found -> + failwith "LOAD: unterminated filename" + in + let fn = String.sub args 1 (close - 1) in + let rest = String.strip ( + String.sub args (close + 1) + (String.length args - close - 1)) in + (fn, rest) + else + match String.split_on_char ' ' args with + | [] -> failwith "LOAD: missing filename" + | [f] -> (f, "") + | f :: rest -> (f, String.concat " " rest) + in + if filename = "" then failwith "LOAD: missing filename"; + (* Checked here, before anything else touches the session: the + reader would otherwise raise [Sys_error] far downstream, and + the REPL would report it as an anomaly after having already + reset the scope. *) + (match EcLlmCore.check_load_file filename with + | Ok () -> () + | Error msg -> failwith msg); + + (* Parse optional LINE[:COL] and flags (-nosmt, -noproof, + -trace). *) + let upto, nosmt, noproof, trace = + let words = + String.split_on_char ' ' rest + |> List.filter (fun s -> s <> "") + in + let nosmt = List.mem "-nosmt" words in + let noproof = List.mem "-noproof" words in + let trace = List.mem "-trace" words in + let words = + List.filter + (fun s -> + s <> "-nosmt" && s <> "-noproof" && s <> "-trace") + words + in + let upto = match words with + | [] -> None + | [w] -> + begin match String.split_on_char ':' w with + | [line] -> + Some (int_of_string line, None) + | [line; col] -> + Some (int_of_string line, Some (int_of_string col)) + | _ -> failwith "LOAD: invalid LINE[:COL] format" + end + | _ -> failwith "LOAD: unexpected arguments" + in + (upto, nosmt, noproof, trace) + in + Load { ld_file = filename; ld_upto = upto; ld_nosmt = nosmt; + ld_noproof = noproof; ld_trace = trace; } + with Failure msg -> raise (Parse_error msg) + + let of_line ~multi_active (raw : string) : command = + let line = String.strip raw in + if multi_active then + if line = "" then Done_multi + else Multi_line line + else + match line with + | "" -> Begin_multi + | "" -> Blank + | "QUIT" -> Quit + | "HELP" -> Help + | "UNDO" -> Undo + | "GOALS" -> Goals `One + | "GOALS ALL" -> Goals `All + | "TREE" -> Tree `One + | "TREE ALL" -> Tree `All + | "COMMIT" -> Commit + | "NEXT" -> Next + | "QUIET ON" -> Quiet true + | "QUIET OFF" -> Quiet false + | "STRICT ON" -> Strict true + | "STRICT OFF"-> Strict false + | "RESUME" -> Resume + | _ -> + match keyword_arg "FOCUS" line with Some a -> parse_focus a | None -> + match keyword_arg "CHECKPOINT" line with Some a -> parse_checkpoint a | None -> + match keyword_arg "REVERT" line with Some a -> parse_revert a | None -> + match keyword_arg "SEARCH" line with Some a -> parse_search a | None -> + match keyword_arg "LOAD" line with Some a -> parse_load a | None -> + Ec line +end + +(* -------------------------------------------------------------------- *) +let run ~relocdir ~boot ~projini (llmopts : EcOptions.llm_option) = + if llmopts.llmo_help then begin + print_llm_guide (); + exit 0 + end; + + let prvopts = llmopts.llmo_provers in + + let st = + try EcLlmCore.create ~relocdir ~boot ~projini ~prvopts + with EcLlmCore.Init_error msg -> + Format.eprintf "%s" msg; + exit 1 + in + + (* True iff replies should suppress goal bodies. Toggled by QUIET. *) + let quiet = ref false in + + (* ------------------------------------------------------------------ *) + (* Exit status. Scripted runs (-eval) report in-band errors through + it, so that automation does not mistake an ERROR reply for success; + interactive sessions always exit 0. + + Every way out of the REPL goes through [terminate]: end of input, + QUIT, and an [exit.] phrase alike. Routing QUIT and [exit.] around + it is how the contract used to be lost -- and a script ending in + QUIT, which is the natural thing to write, is exactly the case + that lost it. *) + let had_error = ref false in + + let terminate () = + exit (if llmopts.llmo_eval <> None && !had_error then 1 else 0) + in + + let module Wire = struct + (* Write a chunk of reply body, one line at a time, escaping the + lines that would collide with the envelope. The chunk is + terminated with a newline if it lacks one, so that whatever + follows -- another chunk, or the [] sentinel -- starts a + line of its own. *) + let print_body (text : string) = + let lines = + match List.rev (String.split_lines text) with + | "" :: rest -> List.rev rest (* the final newline's tail *) + | rev -> List.rev rev + in + List.iter + (fun line -> + if envelope_shaped line then print_char ' '; + print_string line; + print_char '\n') + lines + + let reply_ok (r : EcLlmCore.reply) = + let body = + match r.EcLlmCore.body with + | EcLlmCore.Text body -> body + | EcLlmCore.Goals -> + if !quiet then "" else EcLlmCore.current_goals st + in + Printf.printf "OK [uuid:%d]%s\n" r.EcLlmCore.uuid r.EcLlmCore.tag; + let n = r.EcLlmCore.notices in + if n <> "" then print_body n; + if body <> "" then print_body body; + Printf.printf "\n%!" + + let reply_failure (f : EcLlmCore.failure) = + had_error := true; + let goals = f.EcLlmCore.goals in + Printf.printf "ERROR [uuid:%d]\n" f.EcLlmCore.uuid; + print_body (f.EcLlmCore.message ^ "\n"); + if goals <> "" then print_body goals; + Printf.printf "\n%!" + + (* Render an operation's outcome. *) + let reply = function + | Ok reply -> reply_ok reply + | Error failed -> reply_failure failed + + (* Same, for operations that may end the session. *) + let answer = function + | EcLlmCore.Quit -> terminate () + | EcLlmCore.Done outcome -> reply outcome + + let reply_error msg = + reply_failure (EcLlmCore.make_failure st msg) + end in + + (* ------------------------------------------------------------------ *) + (* Command handlers. Each takes (already-parsed) data and produces a + wire reply via [Wire] (or exits the process). Multi-line state is + held here so [Parse] can stay pure. *) + let multi_buf = Buffer.create 256 in + let in_multi = ref false in + + let module Dispatch = struct + let do_help () = + EcLlmCore.clear_notices st; + let buf = Buffer.create 4096 in + let path = llm_guide_path () in + begin try + let ic = open_in path in + begin try while true do + Buffer.add_char buf (input_char ic) + done with End_of_file -> () end; + close_in ic; + Wire.reply_ok + (EcLlmCore.make_reply st (EcLlmCore.Text (Buffer.contents buf))) + with Sys_error e -> + Wire.reply_error (Printf.sprintf "cannot read guide: %s" e) + end + + let do_quiet on = + EcLlmCore.clear_notices st; + quiet := on; + Wire.reply_ok (EcLlmCore.make_reply st (EcLlmCore.Text "")) + + let do_begin_multi () = + Buffer.clear multi_buf; + in_multi := true + + let do_done_multi () = + let input = Buffer.contents multi_buf in + Buffer.clear multi_buf; + in_multi := false; + if input <> "" then Wire.answer (EcLlmCore.step st input) + + let do_multi_line s = + if Buffer.length multi_buf > 0 then + Buffer.add_char multi_buf ' '; + Buffer.add_string multi_buf s + + let run (cmd : Parse.command) = + match cmd with + | Blank -> () + | Quit -> terminate () + | Help -> do_help () + | Undo -> Wire.reply (EcLlmCore.undo st) + | Goals `One -> Wire.reply (EcLlmCore.goals st ~all:false) + | Goals `All -> Wire.reply (EcLlmCore.goals st ~all:true) + | Tree `One -> Wire.reply (EcLlmCore.tree st ~all:false) + | Tree `All -> Wire.reply (EcLlmCore.tree st ~all:true) + | Commit -> Wire.reply (EcLlmCore.commit st) + | Focus path -> Wire.reply (EcLlmCore.focus st (`Path path)) + | Next -> Wire.reply (EcLlmCore.focus st `Next) + | Checkpoint n -> Wire.reply (EcLlmCore.checkpoint st ~name:n) + | Revert s -> Wire.reply (EcLlmCore.revert st s) + | Quiet on -> do_quiet on + | Strict on -> Wire.reply (EcLlmCore.strict st ~on) + | Resume -> Wire.reply (EcLlmCore.resume st) + | Search q -> Wire.reply (EcLlmCore.search st ~pattern:q) + | Load args -> + Wire.reply (EcLlmCore.load st + ~file:args.Parse.ld_file + ~upto:args.Parse.ld_upto + ~nosmt:args.Parse.ld_nosmt + ~noproof:args.Parse.ld_noproof + ~trace:args.Parse.ld_trace) + | Ec input -> Wire.answer (EcLlmCore.step st input) + | Begin_multi -> do_begin_multi () + | Done_multi -> do_done_multi () + | Multi_line s -> do_multi_line s + end in + + (* ------------------------------------------------------------------ *) + (* Main loop. *) + + Printf.printf "READY [uuid:%d]\n\n%!" (EcLlmCore.uuid st); + + (* Input source: stdin by default, or the -eval string when given. + For -eval, we split on newlines up front (no lazy channel), which + keeps the driver simple and avoids ever touching stdin. *) + let read_line : unit -> string = + match llmopts.llmo_eval with + | None -> + fun () -> input_line stdin + | Some script -> + let lines = ref (String.split_on_char '\n' script) in + fun () -> + match !lines with + | [] -> raise End_of_file + | l :: tl -> lines := tl; l + in + + begin try while true do + let line = read_line () in + (try + let cmd = Parse.of_line ~multi_active:!in_multi line in + Dispatch.run cmd + with Parse.Parse_error msg -> + Wire.reply_error msg) + done with + | End_of_file -> () + end; + + terminate () diff --git a/src/ecLlm.mli b/src/ecLlm.mli new file mode 100644 index 000000000..28b9896d5 --- /dev/null +++ b/src/ecLlm.mli @@ -0,0 +1,20 @@ +(* -------------------------------------------------------------------- *) +(* The LLM coding-agent REPL: an interactive proof-development protocol + over stdin/stdout. Driven via the [easycrypt llm] command. *) + +(* Path to the bundled agent guide ([doc/llm/CLAUDE.md] in a source + tree, its installed copy otherwise). [llm -help] and the [HELP] + command print the whole of it; exposed because [mcp -help] prints one + section of the same file. *) +val llm_guide_path : unit -> string + +(* Run the REPL until [QUIT] or EOF, then exit the process. Never + returns. [projini] resolves the [easycrypt.project] context of a + file path, so [LOAD] can apply the project's load path and prover + options the way the batch compiler does. *) +val run : + relocdir:string option + -> boot:bool + -> projini:(string option -> EcOptions.ini_context option) + -> EcOptions.llm_option + -> 'a diff --git a/src/ecLlmCore.ml b/src/ecLlmCore.ml new file mode 100644 index 000000000..e393fb384 --- /dev/null +++ b/src/ecLlmCore.ml @@ -0,0 +1,1562 @@ +(* -------------------------------------------------------------------- *) +(* Engine-facing core of the LLM interaction protocol. See + [ecLlmCore.mli]. This module owns the session state and implements + one function per meta-command; it never prints and never exits. The + text envelope ([OK]/[ERROR]/[]) is the front-end's business. *) + +open EcUtils + +module EP = EcParsetree + +(* -------------------------------------------------------------------- *) +type body = + | Goals + | Text of string + +type reply = { + uuid : int; + tag : string; + notices : string; + body : body; + changed : bool; +} + +type failure = { + uuid : int; + message : string; + goals : string; + notices : string; + reverted : bool; + changed : bool; +} + +type answer = + | Done of (reply, failure) result + | Quit + +exception Init_error of string + +(* -------------------------------------------------------------------- *) +(* One recorded REPL phrase, as [Commit] needs to see it again. + + [en_penv] is the proof DAG as of just after the phrase ran, and it + is per entry rather than per session on purpose: a session holding + several lemmas has one DAG per lemma, handles are only meaningful in + their own, and a single newest-wins snapshot answered "no parent" + for every handle of every earlier proof -- which rendered those + proofs flat, without their bullets. *) +type entry = { + (* Engine uuid right before the phrase; UNDO/REVERT trim on it. *) + en_uuid : int; + en_src : string; + (* Focused handle right before the phrase; [None] iff outside a + proof, which is also what separates one proof from the next. *) + en_parent : EcCoreGoal.handle option; + (* Full open-handle list (focused first) right before the phrase, + used to seed the sibling map when the first recorded phrase of a + proof already sits inside a frame opened by the LOAD prefix. *) + en_opens : EcCoreGoal.handle list; + en_penv : EcCoreGoal.proofenv option; +} + +(* -------------------------------------------------------------------- *) +(* Session state. The proof engine ([EcCommands]) is a global mutable + singleton, so at most one [state] may exist per process. *) +type state = { + (* Prover options as given on the command line: the base [LOAD] + overlays the loaded file's [easycrypt.project] settings onto. *) + base_prvopts : EcOptions.prv_options; + + (* Resolves the [easycrypt.project] context of a file path. *) + projini : string option -> EcOptions.ini_context option; + + boot : bool; + + (* Prover options in effect: refreshed by [LOAD] with the loaded + file's [easycrypt.project] settings overlaid on the command-line + options, as the batch compiler does at option-parsing time. *) + cur_prvopts : EcOptions.prv_options ref; + + (* Messages emitted by the engine during a phrase; flushed into the + next reply. *) + notices : Buffer.t; + + (* Has [EcCommands.initialize] been called? Subsequent calls pass + [~restart:true]. *) + initialized : bool ref; + + (* The include path as the session started: the prelude and stdlib + roots, the command line's -I/-R/-stdlib entries, and the working + directory. Every [LOAD] rewinds the (process-global) loader to it + before adding the loaded file's own directory and its project's, + so one file's neighbours are never visible to the next. *) + base_loadpath : EcCommands.loadpath_mark; + + (* CHECKPOINT name -> uuid. *) + checkpoints : (string, int) Hashtbl.t; + + (* Transcript of REPL-typed phrases that succeeded, newest first. + Trimmed by UNDO/REVERT; cleared on LOAD/Restart. *) + transcript : entry list ref; + + (* The bullet stack of the active proof at the moment REPL input + took over. Captured the first time [disable_repl_bullets] clears + a non-empty stack. Used by [Commit] to pick bullet characters + that don't collide with frames opened by the LOAD prefix. + Cleared with the transcript on LOAD/Restart. *) + prior_bullets : EcBullets.stack option ref; + + (* Strict mode: is a failure allowed to be followed by more input? + Off, a session behaves as a file does -- the failure is reported + and whatever comes next is run against wherever it left the + engine. On, the session stops there instead. *) + strict_mode : bool ref; + + (* Set, while strict, to the phrase a failure stopped the session + at. Every operation that could move the engine refuses until the + session is resynchronized ([undo], [revert], [load], [resume]), + so a client that sends its phrases one at a time cannot walk past + the failure and carry on against a state it did not mean. *) + stopped_at : string option ref; +} + +(* -------------------------------------------------------------------- *) +let checkmode_of (prvopts : EcOptions.prv_options) = { + EcCommands.cm_checkall = prvopts.prvo_checkall; + EcCommands.cm_timeout = odfl 3 prvopts.prvo_timeout; + EcCommands.cm_cpufactor = odfl 1 prvopts.prvo_cpufactor; + EcCommands.cm_nprovers = odfl 4 prvopts.prvo_maxjobs; + EcCommands.cm_provers = prvopts.prvo_provers; + EcCommands.cm_quorum = prvopts.prvo_quorum; + EcCommands.cm_profile = prvopts.prvo_profile; +} + +let notifier (st : state) = + fun (_ : EcGState.loglevel) (lazy msg) -> + Buffer.add_string st.notices msg; + Buffer.add_char st.notices '\n' + +let do_initialize (st : state) = + let initialized = st.initialized in + let cur_prvopts = st.cur_prvopts in + EcCommands.initialize + ~restart:!initialized ~undo:true + ~boot:st.boot ~checkmode:(checkmode_of !cur_prvopts) ~checkproof:true; + initialized := true; + (try + List.iter EcCommands.apply_pragma_option !cur_prvopts.prvo_pragmas + with EcCommands.InvalidPragma x -> + EcScope.hierror "invalid pragma: `%s'\n%!" x); + EcCommands.addnotifier (notifier st); + oiter (fun ppwidth -> + let gs = EcEnv.gstate (EcScope.env (EcCommands.current ())) in + EcGState.setvalue "PP:width" (`Int ppwidth) gs) + !cur_prvopts.prvo_ppwidth + +(* -------------------------------------------------------------------- *) +let create ~relocdir ~boot ~projini ~prvopts = + Random.self_init (); + + prvopts.EcOptions.prvo_why3server |> oiter (fun server -> + try + Why3.Prove_client.connect_external server + with Why3.Prove_client.ConnectionError e -> + raise (Init_error (Format.asprintf + "cannot connect to Why3 server `%s': %s" server e))); + + (match relocdir with + | None -> EcCommands.addidir Filename.current_dir_name + | Some pwd -> EcCommands.addidir pwd); + + let st = { + base_prvopts = prvopts; + projini; + boot; + cur_prvopts = ref prvopts; + notices = Buffer.create 256; + initialized = ref false; + base_loadpath = EcCommands.loadpath_mark (); + checkpoints = Hashtbl.create 16; + transcript = ref []; + prior_bullets = ref None; + strict_mode = ref false; + stopped_at = ref None; + } in + + (* [print] renders on the process's stdout by default, which in the + REPL lands *before* the reply's status line -- outside the frame -- + and under MCP is swallowed whole, stdout being pointed at stderr + there. Send it to the notice buffer, where the engine's other + messages, [search] and [locate] included, already arrive. *) + EcCommands.set_print_formatter (Format.formatter_of_buffer st.notices); + + (* A session reloads: LOAD rebuilds the scope on every call, and so + does [pragma restart.]. Without this each one re-reads every + theory the file requires, which is nearly all of what a LOAD + costs. *) + EcCommands.enable_theory_cache (); + + do_initialize st; st + +(* -------------------------------------------------------------------- *) +(* Goal/error formatting: shared between the reply layer and the + -trace block. *) +module Goals = struct + let format_error ?(src="") e = + let base = match e with + | EcScope.TopError (loc, e) -> + let msg = String.strip (EcPException.tostring e) in + if loc = EcLocation._dummy then msg + else Format.asprintf "%s: %s" (EcLocation.tostring loc) msg + | e -> + String.strip (EcPException.tostring e) + in + if src = "" then base + else Printf.sprintf "%s\nsource: %s" base src + + let goals_to_string ?(all=false) () = + let buf = Buffer.create 256 in + let fmt = Format.formatter_of_buffer buf in + EcCommands.pp_current_goal_or_noproof ~all fmt; + Format.pp_print_flush fmt (); + Buffer.contents buf + + (* Inline focus annotation ([focus: 1/N]) appended to reply tags + whenever the active proof has >=2 open subgoals. Counted on the + handles: [pp_tree] enumerates the same goals under the same guard, + but renders each one's conclusion on the way, and every reply that + ends on the goals asks for this tag. *) + let focus_tag () = + match EcCommands.open_handles () with + | _ :: _ :: _ as handles -> + Printf.sprintf " [focus: 1/%d]" (List.length handles) + | _ -> "" +end + +(* -------------------------------------------------------------------- *) +(* Frame tree: group currently-open goals by their shared multi-child + ancestors. Used by [Tree] (rendering) and [Focus] (path lookup). + The tree is a *derivation*: it depends only on [pr_opened] and + [parent_of], no recorded transcript. *) +module FrameTree = struct + (* Internal nodes are split-point frames; leaves carry a handle + (the open goal), its index in [pr_opened] (1-based, used by + [EcCoreGoal.rotate_focus]), and its rendered text. *) + type node = + | Frame of node list (* >=2 child branches *) + | Leaf of + { idx : int (* 1-based in pr_opened *) + ; focused : bool (* idx = 1 *) + ; text : string } (* one-line conclusion *) + + (* Multi-child ancestors of [h], outermost first (= root-most + split first, deepest split last). This ordering means leaves + sharing the same OUTER frame will agree on the chain's first + element, which is what [group] partitions on. *) + let split_chain h = + let rec walk h acc = + match EcCommands.parent_of h with + | None -> acc + | Some p -> + match EcCommands.children_of p with + | [_] -> walk p acc + | _ -> walk p (p :: acc) + in + (* [walk] prepends each ancestor as we go up; the result has + outermost at the FRONT (we add it last). No reverse needed. *) + walk h [] + + (* Build the tree by grouping leaves with a common ancestor prefix. + [leaves] is a list of (chain, leaf) in [pr_opened] order. The + grouping is done recursively on the head of each chain. *) + let rec group (leaves : (EcCoreGoal.handle list * node) list) : node list = + let rec runs acc = function + | [] -> List.rev acc + | (chain, leaf) :: rest -> + match chain with + | [] -> runs (`Bare leaf :: acc) rest + | hd :: tl -> + let same_head, others = + List.partition_map (fun (c, l) -> + match c with + | h :: tail when EcCoreGoal.eq_handle h hd -> + Left (tail, l) + | _ -> Right (c, l)) + rest + in + runs (`Group ((tl, leaf) :: same_head) :: acc) others + in + List.map + (function + | `Bare leaf -> leaf + | `Group children -> Frame (group children)) + (runs [] leaves) + + (* Strip leading singleton frames so the top-level forest's + indices match what the user thinks of as "top-level subgoals + of the current frame." When all open leaves descend from a + single outermost split, the top-level forest has one Frame + containing the actual user-visible siblings; unwrap it. *) + let rec unwrap forest = + match forest with + | [Frame children] -> unwrap children + | _ -> forest + + let build () = + let handles = EcCommands.open_handles () in + let texts = EcCommands.pp_tree () in + if handles = [] then [] + else + let leaves = + List.mapi (fun i (h, (e : EcCommands.goal_entry)) -> + let leaf = + Leaf { idx = i + 1; focused = e.ge_focused; text = e.ge_text } + in + (split_chain h, leaf)) + (List.combine handles texts) + in + unwrap (group leaves) + + (* Render the tree with dotted-path labels matching what FOCUS + accepts. [all] requests full goal bodies (we re-query via + [pp_tree ~all:true] keyed by leaf index). *) + let render ?(all=false) () = + let forest = build () in + if forest = [] then "No active proof.\n" + else + let texts_all = + if all then Some (EcCommands.pp_tree ~all:true ()) + else None + in + let one_line s = + let s = + match String.index_opt s '\n' with + | None -> s + | Some k -> String.sub s 0 k + in + let limit = 80 in + if String.length s > limit + then String.sub s 0 (limit - 1) ^ "…" + else s + in + let buf = Buffer.create 256 in + let rec emit ~depth ~path = function + | Leaf { idx; focused; text } -> + let label = String.concat "." (List.rev_map string_of_int path) in + let marker = if focused then " <- focused" else "" in + for _ = 1 to depth do Buffer.add_string buf " " done; + (match texts_all with + | None -> + Buffer.add_string buf + (Printf.sprintf "[%s] %s%s\n" + label (one_line text) marker) + | Some entries -> + let e : EcCommands.goal_entry = List.nth entries (idx - 1) in + Buffer.add_string buf + (Printf.sprintf "[%s]%s\n%s\n" label marker e.ge_text)) + | Frame children -> + List.iteri (fun i child -> + emit ~depth:(depth + 1) ~path:((i + 1) :: path) child) + children + in + List.iteri (fun i node -> + emit ~depth:0 ~path:[i + 1] node) + forest; + Buffer.contents buf + + (* Resolve a dotted path against the tree. Returns [Ok idx] where + [idx] is the 1-based position in [pr_opened] of the selected + leaf, or [Error msg]. *) + let resolve_path (path : int list) : (int, string) result = + let forest = build () in + let rec walk ~components nodes = + match components with + | [] -> Error "FOCUS: path must select a leaf goal" + | k :: rest -> + if k < 1 || k > List.length nodes then + Error (Printf.sprintf + "FOCUS: index %d out of range (1..%d)" + k (List.length nodes)) + else + match List.nth nodes (k - 1), rest with + | Leaf { idx; _ }, [] -> Ok idx + | Leaf _, _ -> + Error "FOCUS: path overshoots a leaf goal" + | Frame _, [] -> + Error "FOCUS: path must select a leaf goal, \ + not a frame" + | Frame kids, _ -> walk ~components:rest kids + in + if forest = [] then Error "FOCUS: no active proof" + else walk ~components:path forest +end + +(* -------------------------------------------------------------------- *) +(* Reply construction. The notice buffer is captured and cleared at + exactly the points the text front-end used to print it, so that + engine messages keep interleaving with replies as before. *) +let mk_reply (st : state) ~(pre : int) ?(tag = "") (body : body) = + let notices = Buffer.contents st.notices in + Buffer.clear st.notices; + let uuid = EcCommands.uuid () in + { uuid; tag; notices; body; changed = uuid <> pre; } + +(* The body of a reply that ends on the current goals. The front-end + decides whether to render them (QUIET is a presentation setting). *) +let mk_reply_goals (st : state) ~(pre : int) = + let tag = Goals.focus_tag () in + mk_reply st ~pre ~tag Goals + +let mk_failure (st : state) ~(pre : int) (message : string) = + let notices = Buffer.contents st.notices in + Buffer.clear st.notices; + let uuid = EcCommands.uuid () in + { uuid; message; goals = Goals.goals_to_string (); notices; + reverted = false; changed = uuid <> pre; } + +(* -------------------------------------------------------------------- *) +(* Strict mode. + + A session is a file that is still being written: a failure is + reported and whatever is sent next runs against wherever it left the + engine, exactly as the sentences after a failure in a file would. + That is the right default, and it is also the trap a client that + sends its phrases one at a time falls into -- it keeps sending, + each phrase lands on a state one phrase further from the one it was + written against, and the drift is only noticed later. + + Under strict mode the session stops instead. Any failure of an + operation that could have advanced arms [stopped_at] -- whether or + not that particular failure did advance, since the drift is in the + client's picture of where the session is, not in the engine: the + phrase after a failed one was written for the state the failed one + was to produce, and runs against the state before it either way. + Every operation that could move the engine further is then refused + until the session is put somewhere the client chose: [undo], + [revert] and [load] do that by arriving somewhere definite, and + [resume] by saying so. Reading is never refused -- the point is to + look at the failure, not to be locked out of it -- so goals, trees, + searches, checkpoints and COMMIT answer while stopped. + + [try_step] is the one advancing operation whose failures do not arm + it: they restore the state the call started from and say so + ([reverted]), so the client's picture stays exact and there is no + drift to prevent. It is still refused *while* stopped, since + succeeding would advance from a point the client has not + acknowledged. *) +module Strict = struct + let stopped (st : state) = + !(st.strict_mode) && !(st.stopped_at) <> None + + (* Arm the stop. [at] is how the reply will name the phrase that + stopped the session. Only the first failure arms it: what the + client needs is where it stopped being in control, not where the + last refusal happened. *) + let arm (st : state) (at : string) = + if !(st.strict_mode) && !(st.stopped_at) = None then + st.stopped_at := Some (if at = "" then "" else at) + + let clear (st : state) = + st.stopped_at := None + + (* The refusal. It carries the phrase that stopped the session and + the ways out, because a client that hits this has by definition + lost track of where the session is. *) + let refuse (st : state) ~(pre : int) = + let at = odfl "" !(st.stopped_at) in + mk_failure st ~pre (Printf.sprintf + "strict: the session stopped at a failed phrase and has not been \ + resynchronized\nstopped at: %s\nUNDO, REVERT, LOAD or RESUME to \ + continue; GOALS, TREE, SEARCH and COMMIT answer meanwhile" at) +end + +(* -------------------------------------------------------------------- *) +(* Transcript manipulation. *) +module Transcript = struct + let trim (st : state) target = + let transcript = st.transcript in + transcript := + List.filter (fun e -> e.en_uuid < target) !transcript + + let clear (st : state) = + st.transcript := []; + st.prior_bullets := None +end + +(* -------------------------------------------------------------------- *) +(* Full session reset. Both LOAD and a [pragma restart.] destroy the + uuid space, so every piece of bookkeeping keyed on engine uuids goes + with it: a surviving checkpoint would name a state of a session that + no longer exists, and REVERT would resolve it. Shared by [step] and + [load] so the two paths cannot drift apart again. *) +let reset_session (st : state) : unit = + do_initialize st; + Hashtbl.clear st.checkpoints; + Transcript.clear st; + (* A strict stop names a phrase of a session that no longer exists. *) + st.stopped_at := None + +(* -------------------------------------------------------------------- *) +(* Process a single EasyCrypt command, respecting [gl_fail]. When + [~record:true], append a transcript entry on success: the parent + handle (focused goal before the phrase) and the open-handle list, + which together let [Commit] reconstruct bullet structure. + + [~nofail:true] drops the [gl_fail] verdict -- the sentence is still + run, and a failure is still swallowed, but succeeding is no longer + an error. LOAD -noproof sets it inside a proof it is skipping: there + the tactic is not run at all, so it cannot fail, and a `fail tac.' + the file wrote to pin an error would otherwise fail the load. *) +let process_action (st : state) ?(record=false) ?(nofail=false) ~src + (p : EP.global) = + let transcript = st.transcript in + let loc = p.EP.gl_action.EcLocation.pl_loc in + let pre_uuid = EcCommands.uuid () in + let opens_pre = + if record then EcCommands.open_handles () else [] + in + let parent = + match opens_pre with h :: _ -> Some h | [] -> None + in + (* Queries only inspect the environment: they neither advance the + proof nor belong in the body COMMIT emits. *) + let is_query = + match EcLocation.unloc p.EP.gl_action with + | EP.Gprint _ | EP.Gsearch _ | EP.Glocate _ -> true + | _ -> false + in + let succeeded = ref false in + begin try + ignore (EcCommands.process ~src p.EP.gl_action : float option); + succeeded := true + with + | EcCommands.Restart -> raise EcCommands.Restart + | _ when p.EP.gl_fail -> () + | e -> raise (EcScope.toperror_of_exn ~gloc:loc e) + end; + (* The engine pushes an undo context for every command it runs, a + query included -- with the *same* scope, since a query returns the + scope it was handed. Pop it back off: a read-only command must not + spend a uuid, or REVERT targets and the MCP [readOnlyHint] would + both be lying. A no-op when the query failed (nothing was pushed). *) + if is_query then EcCommands.undo pre_uuid; + if !succeeded && p.EP.gl_fail && not nofail then + raise (EcScope.toperror_of_exn ~gloc:loc + (EcScope.HiScopeError (None, + "this command is expected to fail"))); + if record && !succeeded && not p.EP.gl_fail && not is_query then + (* The DAG is snapshot here, per phrase: a [proofenv] is immutable + and cumulative *within one proof*, so this entry's snapshot + answers for every handle its own proof can mention -- and, being + its own, keeps answering after [qed] has discarded the proof and + a later lemma has replaced it. A phrase that ends the proof + leaves none active and so records [None]; such a phrase is + outside a proof ([en_parent = None]) and its DAG is never + consulted. *) + transcript := + { en_uuid = pre_uuid; + en_src = src; + en_parent = parent; + en_opens = opens_pre; + en_penv = EcCommands.current_proofenv (); } :: !transcript + +(* -------------------------------------------------------------------- *) +(* COMMIT: replay the transcript against the proof DAG (parent_of / + children_of, backed by [EcCoreGoal.pr_parent]), inserting bullets + at multi-child splits. Levels the LOAD prefix's [puc_bullets] stack + already opened are addressed with that frame's own token; deeper + levels get fresh tokens, chosen so they collide with neither the + stack nor each other. *) +module Commit = struct + (* Token order matches PR 1017's lexer: -, +, *, --, ++, **, + ---, +++, *** ... *) + let token_at_index i = + let chars = [| "-"; "+"; "*" |] in + let rep = i / 3 + 1 in + let chr = chars.(i mod 3) in + String.concat "" (List.init rep (fun _ -> chr)) + + (* DAG queries go through the snapshot the entry recorded, so COMMIT + still sees the structure of a proof [qed] has since discarded -- + and sees the *right* one when the session holds several. Fall back + to the live proof for an entry that recorded no snapshot. *) + let parent_of penv h = + match penv with + | Some penv -> EcCoreGoal.parent_of_handle penv h + | None -> EcCommands.parent_of h + + let children_of penv h = + match penv with + | Some penv -> EcCoreGoal.children_of_handle penv h + | None -> EcCommands.children_of h + + (* Position of [h] in the proof DAG: the child indices on the path + from the root down to [h]. Lexicographic order on those paths is + the DAG's preorder, which is the order in which a proof body has + to discharge the subgoals -- and, FOCUS/NEXT being free to jump + between open goals, not the order the phrases were typed in. *) + let dag_path penv (h : EcCoreGoal.handle) = + let rec walk h acc = + match parent_of penv h with + | None -> acc + | Some p -> + let rec index i = function + | [] -> i + | c :: cs -> + if EcCoreGoal.eq_handle c h then i else index (i + 1) cs + in + walk p (index 0 (children_of penv p) :: acc) + in + walk h [] + + (* Cut the transcript at its proof boundaries. A phrase typed outside + a proof ([en_parent = None]) is a [`Barrier] -- the lemma statement + that opens a proof, the [qed] that closes it -- and each run of + in-proof phrases between two of them belongs to exactly one proof. + Both the DAG sort and the bullet state below are per proof, and + this is what delimits one. *) + let blocks entries = + let close acc run = if run = [] then acc else `Run (List.rev run) :: acc in + let rec walk acc run = function + | [] -> List.rev (close acc run) + | ({ en_parent = None; _ } as e) :: rest -> + walk (`Barrier e :: close acc run) [] rest + | e :: rest -> walk acc (e :: run) rest + in + walk [] [] entries + + (* Reorder one proof's phrases into DAG order, so that a body typed + out of order (FOCUS 2, prove the second goal, come back to the + first) still replays top to bottom. The sort is stable, so entries + the DAG does not order keep their typing order. *) + let dag_order run = + let key e = + match e.en_parent with + | None -> [] + | Some h -> dag_path e.en_penv h + in + List.stable_sort + (fun a b -> compare (key a : int list) (key b)) + run + + let bullet_to_string (b : EcParsetree.bullet) = + let ch = + match b.b_kind with + | `Minus -> "-" + | `Plus -> "+" + | `Star -> "*" + in + String.concat "" (List.init b.b_count (fun _ -> ch)) + + let proof_text (st : state) = + let buf = Buffer.create 1024 in + let emit_indent depth = + for _ = 1 to depth do Buffer.add_string buf " " done + in + let module Hmap = + Map.Make (struct + type t = EcCoreGoal.handle + let compare = compare + end) + in + (* Render one proof's phrases. Every piece of bullet state -- + the sibling map, the current depth, the token reserved at each + depth -- is local to this call, so one lemma can neither inherit + another's indentation nor exhaust its token supply. + [frames] are the bullet frames the LOAD prefix left open; they + belong to the proof that was in progress when the REPL took + over, hence to the first run only. *) + let render_run ~(frames : EcBullets.frame list) run = + let sibling_depth : int Hmap.t ref = ref Hmap.empty in + let current_depth = ref 0 in + let in_use_tokens = + List.map + (fun (f : EcBullets.frame) -> bullet_to_string f.bf_bullet) + frames + in + let depth_cache : (int, string) Hashtbl.t = Hashtbl.create 8 in + let next_tok_idx = ref 0 in + let assigned_tokens = ref [] in + (* Depths 1..k address the next sibling of a frame the prefix + already opened, and strict bullets accepts nothing but that + frame's own token there. Deeper levels get fresh tokens, so + pre-populate the cache before any fresh pick happens. *) + List.iteri (fun i (f : EcBullets.frame) -> + let t = bullet_to_string f.bf_bullet in + Hashtbl.replace depth_cache (i + 1) t; + assigned_tokens := t :: !assigned_tokens) + frames; + let bullet_for_depth d = + match Hashtbl.find_opt depth_cache d with + | Some t -> t + | None -> + let rec pick () = + let t = token_at_index !next_tok_idx in + incr next_tok_idx; + if List.mem t in_use_tokens || List.mem t !assigned_tokens + then pick () + else t + in + let t = pick () in + assigned_tokens := t :: !assigned_tokens; + Hashtbl.add depth_cache d t; + t + in + (* Seed: the goals already open when this proof's first recorded + phrase ran were left there by the LOAD prefix, so COMMIT must + place each of them at the depth the prefix's own bullets put it + at. A frame with floor [f] is discharged once [f] goals remain, + hence it still owns the first [n - f] goals of the focused-first + list; a goal covered by [c] frames sits at depth [c + 1]. + Nothing to seed when the prefix left no frame and a single goal + (the REPL just continues on the prefix's own focus). *) + (match run with + | { en_parent = Some _; en_opens = (_ :: _ as opens); en_penv; _ } :: _ + when frames <> [] || List.length opens >= 2 -> + (* [pr_opened] is focused-first, so a FOCUS/NEXT run before the + first recorded phrase leaves it rotated. The floors below + count goals in the order the prefix's bullets consume them, + which is DAG order. *) + let opens = + List.stable_sort + (fun a b -> + compare (dag_path en_penv a : int list) (dag_path en_penv b)) + opens + in + let n = List.length opens in + List.iteri (fun i h -> + let pos = i + 1 in + let covering = + List.length + (List.filter + (fun (f : EcBullets.frame) -> pos <= n - f.bf_floor) + frames) + in + sibling_depth := Hmap.add h (covering + 1) !sibling_depth) + opens + | _ -> ()); + List.iter (fun e -> + match e.en_parent with + | None -> assert false (* [blocks] keeps these out *) + | Some parent -> + let parent_of = parent_of e.en_penv in + let children_of = children_of e.en_penv in + (* Walk upward via pr_parent until we hit a registered + sibling ancestor. If found, emit its bullet and consume + the registration. *) + let rec find_ancestor h = + match Hmap.find_opt h !sibling_depth with + | Some d -> Some (h, d) + | None -> + match parent_of h with + | Some p -> find_ancestor p + | None -> None + in + (match find_ancestor parent with + | Some (h, d) -> + emit_indent (d - 1); + Buffer.add_string buf (bullet_for_depth d); + Buffer.add_char buf ' '; + current_depth := d; + sibling_depth := Hmap.remove h !sibling_depth + | None -> + emit_indent !current_depth); + Buffer.add_string buf e.en_src; + Buffer.add_char buf '\n'; + (* Register fresh siblings: walk the subtree rooted at + [parent], finding every multi-child split, and register + each such child at the right depth. Single-child links + are continuations and don't bump depth; multi-child + links do. A compound phrase like [split; split.] can + produce nested splits within one phrase. *) + let rec walk h d = + match children_of h with + | [c] -> walk c d + | (_ :: _ :: _) as cs -> + List.iter + (fun c -> + sibling_depth := + Hmap.add c d !sibling_depth; + walk c (d + 1)) + cs + | [] -> () + in + walk parent (!current_depth + 1)) + (dag_order run) + in + let prefix_frames : EcBullets.frame list = + (* The stack stores the innermost frame at its head; [render_run] + wants them OUTERMOST first, frame [t_d] being the one whose + siblings live at emitted depth [d]. *) + match !(st.prior_bullets) with + | None -> [] + | Some stack -> List.rev stack + in + let first_run = ref true in + List.iter + (function + | `Barrier e -> + Buffer.add_string buf e.en_src; + Buffer.add_char buf '\n' + | `Run run -> + let frames = if !first_run then prefix_frames else [] in + first_run := false; + render_run ~frames run) + (blocks (List.rev !(st.transcript))); + Buffer.contents buf +end + +(* -------------------------------------------------------------------- *) +(* Accessors used by front-ends to build their own replies (HELP, + QUIET, parse errors) and to render a [Goals] body. *) +let uuid (_ : state) = + EcCommands.uuid () + +let clear_notices (st : state) = + Buffer.clear st.notices + +let current_goals (_ : state) = + Goals.goals_to_string () + +let make_reply (st : state) ?tag (body : body) = + mk_reply st ~pre:(EcCommands.uuid ()) ?tag body + +let make_failure (st : state) (message : string) = + mk_failure st ~pre:(EcCommands.uuid ()) message + +(* -------------------------------------------------------------------- *) +(* Argument checks the two front-ends share. What a command accepts, and + what it says when it refuses, is the same at the prompt as over MCP; + only the way a rejection travels -- a line-parse error there, a + JSON-RPC error or an [isError] result here -- is the front-end's. *) + +(* A dotted goal path, as FOCUS accepts it: "2", "1.2.1". [what] is the + command's name as the calling front-end spells it, and opens the + error message. *) +let parse_goal_path ~(what : string) (arg : string) + : (int list, string) result += + match List.map int_of_string (String.split_on_char '.' arg) with + | exception Failure _ -> + Error (Printf.sprintf "%s: not a path of integers: %s" what arg) + | path when List.exists (fun k -> k < 1) path -> + Error (Printf.sprintf "%s: path indices must be >= 1: %s" what arg) + | path -> Ok path + +(* Reject a LOAD path naming no file. [load] resets the session before + it opens the file, so a front-end that skipped this would report the + reader's [Sys_error] against a session it had already destroyed -- + which is why the check belongs to the caller, and only its wording + here. *) +let check_load_file (file : string) : (unit, string) result = + if Sys.file_exists file then Ok () + else Error (Printf.sprintf "LOAD: no such file: %s" file) + +(* -------------------------------------------------------------------- *) +(* Process EasyCrypt input typed at the prompt. The input is a file + fragment, not a single phrase: every sentence it holds runs, in + order, and one reply describes the state they leave behind. A + failure stops the run there; the sentences before it stay applied, + exactly as they would in a compiled file. *) +let step (st : state) input = + let notices = st.notices in + let prior_bullets = st.prior_bullets in + let pre = EcCommands.uuid () in + Buffer.clear notices; + if Strict.stopped st then Done (Error (Strict.refuse st ~pre)) else begin + (* On the first REPL phrase of each proof, capture the bullet stack + the LOAD prefix left so COMMIT can avoid token collisions with + it. Subsequent calls return [None] and don't clobber the snapshot. *) + (match EcCommands.disable_repl_bullets () with + | None -> () + | Some _ as snapshot -> prior_bullets := snapshot); + let reader = EcIo.from_string input in + let last_src = ref "" in + (* Reply body, decided by the last item that did something: a run of + sentences ends on the goals, a doc comment on an empty body. The + end-of-input marker is an empty [P_Prog], and must not count. *) + let body = ref Goals in + let quit = ref false in + let answer = + begin try + begin try while true do + last_src := ""; + let (src, prog) = EcIo.xparse reader in + let src = String.strip src in + last_src := src; + match EcLocation.unloc prog with + | EP.P_Prog (commands, locterm) -> + if commands <> [] then begin + body := Goals; + List.iter (process_action st ~record:true ~src) commands + end; + if locterm then raise Exit + | EP.P_Undo i -> + body := Goals; + EcCommands.undo i; + Transcript.trim st i + | EP.P_Exit -> + (* Everything before [exit.] stays applied; the front-end + owns what happens next. *) + quit := true; raise Exit + | EP.P_DocComment doc -> + body := Text ""; + EcCommands.doc_comment doc + done with Exit | End_of_file -> () end; + if !quit then Quit else + match !body with + | Goals -> Done (Ok (mk_reply_goals st ~pre)) + | Text _ as b -> Done (Ok (mk_reply st ~pre b)) + with + | EcCommands.Restart -> + reset_session st; + Done (Ok (mk_reply st ~pre (Text "Session restarted"))) + | e -> + Strict.arm st !last_src; + Done (Error (mk_failure st ~pre (Goals.format_error ~src:!last_src e))) + end + in + EcIo.finalize reader; + answer + end + +(* -------------------------------------------------------------------- *) +(* [step] with an automatic rollback on failure. A phrase can fail + after having advanced the engine, so nothing short of the state on + entry is a faithful notion of "unchanged". + + Rolling back with [undo pre] was not enough: [undo] only pops, so + input that *lowered* the uuid before failing -- [undo 3.] followed + by a bad tactic -- left the engine at that lower state while the + reply claimed [reverted = true]. Take a mark of the whole engine + context instead, which restores forward as readily as backward, and + restore the session's own bookkeeping (the transcript, whose entries + carry COMMIT's proof-DAG snapshots, and the prefix's bullet stack) + alongside it rather than + trimming it, since trimming is likewise one-directional. Checkpoints + need nothing: EasyCrypt input cannot reach them, and the uuids they + name are valid again once the engine is back. + + The failure is then re-stamped, because its uuid, goal text and + [changed] flag described the point of failure, which no longer + exists. [changed] is [false] by construction now: the restore always + reaches [pre]. *) +let try_step (st : state) input = + let pre = EcCommands.uuid () in + let mark = EcCommands.undo_mark () in + let transcript = !(st.transcript) in + let bullets = !(st.prior_bullets) in + let stopped_at = !(st.stopped_at) in + match step st input with + | Quit -> Quit + | Done (Ok _) as answer -> answer + | Done (Error failure) -> + EcCommands.undo_restore mark; + st.transcript := transcript; + st.prior_bullets := bullets; + (* Restored like the rest of the bookkeeping: strict mode stops a + session because a failure may have moved the engine, and this + one provably did not. A refusal restores the stop that produced + it, so being refused does not itself change anything. *) + st.stopped_at := stopped_at; + let uuid = EcCommands.uuid () in + Done (Error { failure with + uuid; + goals = Goals.goals_to_string (); + reverted = true; + changed = uuid <> pre; }) + +(* -------------------------------------------------------------------- *) +(* Is [loc] beyond the position LOAD was asked to stop at? [None] as + [upto] means "no bound": load the whole file. A sentence counts as + in-prefix when it *ends* on or before the bound, so LOAD always stops + on a sentence boundary. *) +let past_upto ~upto (loc : EcLocation.t) = + match upto with + | None -> false + | Some (line, col) -> + let (el, ec) = loc.EcLocation.loc_end in + el > line || (el = line && match col with + | None -> false + | Some c -> ec > c) + +(* -------------------------------------------------------------------- *) +(* LOAD -noproof: which proof, if any, is the one the caller is aiming + at. + + Skipping the proofs of a prefix is what [require] already does: it + reads a file with proof checking off, so every lemma is admitted as + it stands (see [EcScope.Prover.check_mode]). The one proof that must + still be checked is the one [upto] points *inside* -- seeing its goal + state is the whole reason for stopping there. + + Which proof that is cannot be known when its opening sentence is + read, so it is settled beforehand, by a parse-only pass over the + prefix: EasyCrypt's grammar does not depend on the environment, and + parsing is nothing next to proving. The pass returns the location of + the sentence that opened the proof still open at [upto]. + + Only two forms leave a proof open across sentences: a [lemma] with no + inline proof, and a [realize] with none. [lemma ... by tac], [clone + ... with proof] and [instance] each open and close within their own + sentence, and a [clone] leaving proof obligations behind opens no + goal until the [realize] that discharges one. [Gsave] -- [qed], + [admitted], [abort] -- closes. + + [`Unsupported] is the safe answer: the caller then loads with + checking on throughout, which is slower and never wrong. It is + returned for a prefix holding an [undo], whose effect on the sentence + stream this pass cannot replay without executing it, and for a prefix + that does not parse -- the real load reports that error, in its own + words and at its own point. *) +let target_proof (filename : string) ~upto + : [`None | `At of EcLocation.t | `Unsupported] += + let reader = EcIo.from_file filename in + let opened = ref `None in + let exception Stop in + + let visit (p : EP.global) = + let loc = p.EP.gl_action.EcLocation.pl_loc in + if past_upto ~upto loc then raise Stop; + match EcLocation.unloc p.EP.gl_action with + | EP.Gaxiom { EP.pa_kind = EP.PLemma None; _ } -> + opened := `At loc + | EP.Grealize { EcLocation.pl_desc = { EP.pr_proof = None; _ }; _ } -> + opened := `At loc + | EP.Gsave _ -> + opened := `None + | _ -> () + in + + let result = + try + while true do + let (_, prog) = EcIo.xparse reader in + match EcLocation.unloc prog with + | EP.P_Prog (commands, locterm) -> + List.iter visit commands; + if locterm then raise Stop + | EP.P_Undo _ -> + if past_upto ~upto (EcLocation.loc prog) then raise Stop; + opened := `Unsupported; raise Stop + | EP.P_Exit -> + raise Stop + | EP.P_DocComment _ -> () + done; + `None + with + | Stop | End_of_file -> !opened + | _ -> `Unsupported + in + + EcIo.finalize reader; result + +(* -------------------------------------------------------------------- *) +(* LOAD: run [file] up to [upto], optionally with SMT calls weakened + ([nosmt]), the proofs of the prefix skipped altogether ([noproof]) + or the last sentence of the prefix traced. The argument string is + parsed by the front-end. *) +let load (st : state) ~file ~upto ~nosmt ~noproof ~trace = + let notices = st.notices in + let cur_prvopts = st.cur_prvopts in + let pre = EcCommands.uuid () in + Buffer.clear notices; + let filename = file in + let last_src = ref "" in + let trace_prefix = ref "" in + let exception Trace_failed of exn in + (* Set once -noproof has turned proof checking off, so the handlers + below -- which are outside the scope of that state -- can put it + back however the load ends. *) + let cleanup = ref (fun () -> ()) in + + try + begin try + ignore (EcLoader.getkind + (Filename.extension filename) : EcLoader.kind) + with EcLoader.BadExtension ext -> + failwith (Format.sprintf + "unknown file extension: %s" ext) + end; + + (* Apply the configuration attached to the loaded file's + [easycrypt.project], as the batch compiler does when the + file is given on the command line: refresh the prover + options (timeout, provers, pragmas, ...) and extend the + load path with the project's include dirs. + + The include path is rewound first. It is process-global and + [addidir] only grows it, so without this a previously loaded + file's directory -- and its project's -- stayed searchable + here, and `require'ing one of its neighbours silently + succeeded in a session that has nothing to do with it. LOAD + resets the session, and the load path is part of the session. *) + let ini = Option.to_list (st.projini (Some filename)) in + cur_prvopts := + EcOptions.prv_options_with_ini ini st.base_prvopts; + EcCommands.loadpath_reset st.base_loadpath; + List.iter (fun (nm, dir, isrec) -> + EcCommands.addidir + ?namespace:(omap (fun nm -> `Named nm) nm) + ~recursive:isrec dir) + (EcOptions.ini_loadpath ini); + + (* The file's own directory joins the include path *before* the + session is rebuilt, not after. The theory cache is keyed on the + include path as it stands at the rebuild -- change it and a name + may resolve to another file, so the cache is dropped -- and the + directory being loaded from is exactly the part of it that a + LOAD of a file elsewhere changes. Added afterwards, it would sit + outside the key, and two files of the same name in two + directories would be served each other's theories. *) + EcCommands.addidir (Filename.dirname filename); + EcCommands.set_current_path (Filename.dirname filename); + + reset_session st; + + (* -noproof: read the prefix the way a [require] is read, with + proof checking off, so every lemma it declares is admitted as it + stands. The proof [upto] falls inside -- if it falls inside one + -- is the exception: checking goes back on at the sentence that + opens it, and its script is replayed for real, which is what + makes the goal state at [upto] the true one. + + The mode is read *after* [reset_session]: that call rebuilds the + engine's scope from scratch, so a mode sampled before it would + describe a scope that no longer exists. It is put back on every + way out, failures included: the session goes on after LOAD, and + phrases typed into it are checked. *) + let saved_check = EcCommands.check_mode () in + let skipping = ref false in + let check_back_at = ref None in + (* Idempotent, and called on every exit path: leaving the engine in + [`Off] would silently admit whatever the session is fed next. *) + let restore_check () = + if !skipping then begin + skipping := false; + check_back_at := None; + EcCommands.set_check_mode saved_check + end + in + + if noproof then begin + let skip loc = + skipping := true; + check_back_at := loc; + EcCommands.set_check_mode `Off + in + cleanup := restore_check; + match target_proof filename ~upto with + | `Unsupported -> () + | `None -> skip None + | `At loc -> skip (Some loc) + end; + + let reader = EcIo.from_file filename in + + let past_upto (loc : EcLocation.t) = past_upto ~upto loc in + + (* Every sentence of the prefix goes through here, so that the + switch back to checked proofs happens when the target sentence is + *run*, not when it is read: under -trace the last sentence of the + prefix is deferred, and the two moments are not the same one. The + test is [>=] rather than an equality on locations so that a + target somehow stepped over still turns checking back on. *) + let run_action ~src (p : EP.global) = + begin match !check_back_at with + | Some (tloc : EcLocation.t) + when p.EP.gl_action.EcLocation.pl_loc.EcLocation.loc_bchar + >= tloc.EcLocation.loc_bchar -> + EcCommands.set_check_mode saved_check; + check_back_at := None + | _ -> () + end; + (* A [fail tac.] inside a proof whose script is being skipped + pins an error that cannot happen any more, the tactic not + being run: honour it and the load fails on a file that + compiles. Outside a proof the sentence is executed for real, + so its verdict still holds. *) + let nofail = + p.EP.gl_fail + && EcCommands.check_mode () = `Off + && EcCommands.in_proof () + in + process_action st ~nofail ~src p + in + + (* [upto] stops the prefix at the requested position whatever kind + of sentence sits past it. This is applied to every item the + reader yields, not only to the [P_Prog] commands: an `undo N.` + on the line after [upto] used to run all the same, silently + rewinding the very prefix the caller asked for, so that LOAD + returned a state that was not the state at that line. *) + let stop_at_upto (item : _ EcLocation.located) = + if past_upto (EcLocation.loc item) then raise Exit + in + + let last_loc = ref None in + + (* For -trace: lazy whole-file bytes, used to slice the exact + source text of a sentence by byte offsets. *) + let input_bytes = lazy ( + let ic = open_in_bin filename in + let n = in_channel_length ic in + let b = Bytes.create n in + really_input ic b 0 n; + close_in ic; + Bytes.unsafe_to_string b) + in + let sentence_source (loc : EcLocation.t) = + let s = Lazy.force input_bytes in + let lo = max 0 loc.EcLocation.loc_bchar in + let hi = min (String.length s) loc.EcLocation.loc_echar in + if hi <= lo then "" else String.sub s lo (hi - lo) + in + + (* For -trace: defer execution of the last sentence within the + prefix so we can capture goals before and after it. *) + let pending : (string * EP.global) option ref = ref None in + let flush_pending () = + match !pending with + | None -> () + | Some (src, p) -> + last_src := src; + run_action ~src p; + last_loc := Some p.EP.gl_action.EcLocation.pl_loc; + pending := None + in + let step src p = + let loc = p.EP.gl_action.EcLocation.pl_loc in + if past_upto loc then raise Exit; + if trace then begin + flush_pending (); + pending := Some (src, p) + end else begin + last_src := src; + run_action ~src p; + last_loc := Some loc + end + in + + if nosmt then EcCommands.pragma_check `WeakCheck; + + begin try while true do + let (src, prog) = EcIo.xparse reader in + let src = String.strip src in + match EcLocation.unloc prog with + | EP.P_Prog (commands, locterm) -> + List.iter (step src) commands; + if locterm then raise Exit + | EP.P_Undo i -> + stop_at_upto prog; + last_src := src; + EcCommands.undo i + | EP.P_Exit -> + raise Exit + | EP.P_DocComment doc -> + stop_at_upto prog; + last_src := src; + EcCommands.doc_comment doc + done with + | Exit | End_of_file -> () + | e -> + EcIo.finalize reader; + if nosmt then EcCommands.pragma_check `Check; + restore_check (); + raise e + end; + + EcIo.finalize reader; + + if nosmt then EcCommands.pragma_check `Check; + (* Kept for the tag below: [restore_check] clears [skipping]. *) + let did_skip = !skipping in + restore_check (); + + (* If -trace is set, the last in-prefix sentence is still + pending. Run it under goal capture and build the + BEFORE/TACTIC/AFTER/SUMMARY response body. *) + let body = + if not trace then + Goals.goals_to_string () + else + let pre_state = + match !pending with + | None -> `Nothing + | Some _ when not (EcCommands.in_proof ()) -> `NotInProof + | Some (src, p) -> `Ready (src, p) + in + match pre_state with + (* Tracing is off the table, but the prefix is not: run the + sentence we deferred so that the session ends up exactly + where a plain LOAD of the same prefix would leave it. A + failure inside the flush is reported by the enclosing + handler, as any prefix failure is. *) + | `Nothing -> + flush_pending (); + failwith "trace: nothing to trace" + | `NotInProof -> + flush_pending (); + failwith + "trace: target sentence is not in a proof context" + | `Ready (src, p) -> + let loc = p.EP.gl_action.EcLocation.pl_loc in + let (sl, sc) = loc.EcLocation.loc_start in + let (el, ec) = loc.EcLocation.loc_end in + let before_goals = EcCommands.pp_all_goals () in + let n1 = List.length before_goals in + let buf = Buffer.create 1024 in + let fmt = Format.formatter_of_buffer buf in + Format.fprintf fmt + "=== BEFORE: line %d (col %d) ===@\n" sl sc; + EcCommands.pp_current_goal_or_noproof ~all:false fmt; + Format.fprintf fmt + "@\n=== TACTIC (lines %d:%d - %d:%d) ===@\n%s@\n@\n" + sl sc el ec (sentence_source loc); + last_src := src; + begin + try + run_action ~src p; + last_loc := Some loc; + pending := None; + let after_goals = EcCommands.pp_all_goals () in + let n2 = List.length after_goals in + Format.fprintf fmt + "=== AFTER: line %d (col %d) ===@\n" sl sc; + let before_set = + List.fold_left + (fun s g -> EcMaps.Sstr.add g s) + EcMaps.Sstr.empty before_goals + in + (* The new focused goal always counts as "modified" + (its focus status changed even if its text matches + an old sibling); the rest are printed only if they + didn't appear in BEFORE. *) + let to_print = + match after_goals with + | [] -> [] + | head :: tl -> + head :: + List.filter + (fun g -> not (EcMaps.Sstr.mem g before_set)) + tl + in + begin match to_print with + | [] -> Format.fprintf fmt "(no open goals)@\n" + | _ -> + List.iteri (fun i g -> + if i > 0 then Format.fprintf fmt "@\n"; + Format.fprintf fmt "%s@\n" g) + to_print + end; + Format.fprintf fmt + "@\n=== SUMMARY ===@\nopen goals: %d -> %d@\n" n1 n2; + Format.pp_print_flush fmt (); + Buffer.contents buf + with e -> + Format.fprintf fmt + "=== AFTER: line %d (col %d) ===@\n@\n" + sl sc; + Format.pp_print_flush fmt (); + trace_prefix := Buffer.contents buf; + raise (Trace_failed e) + end + in + + let tag = + let loaded = + match !last_loc with + | None -> "" + | Some loc -> + let (el, _) = loc.EcLocation.loc_end in + Printf.sprintf " [loaded:%s:%d]" filename el + in + (* The prefix is admitted, not proved: say so, so that a + successful LOAD is not read as a verification of the file. *) + let skipped = if did_skip then " [noproof]" else "" in + loaded ^ skipped ^ Goals.focus_tag () + in + Ok (mk_reply st ~pre ~tag (Text body)) + + with + | EcCommands.Restart -> + reset_session st; + Ok (mk_reply st ~pre (Text "Session restarted")) + | Trace_failed e -> + !cleanup (); + let msg = Goals.format_error ~src:!last_src e in + Error (mk_failure st ~pre (!trace_prefix ^ msg)) + | Failure s -> + !cleanup (); + Error (mk_failure st ~pre s) + | e -> + !cleanup (); + Error (mk_failure st ~pre (Goals.format_error ~src:!last_src e)) + +(* -------------------------------------------------------------------- *) +(* The remaining meta-commands. *) + +let goals (st : state) ~all = + let pre = EcCommands.uuid () in + Buffer.clear st.notices; + Ok (mk_reply st ~pre ~tag:(Goals.focus_tag ()) + (Text (Goals.goals_to_string ~all ()))) + +let tree (st : state) ~all = + let pre = EcCommands.uuid () in + Buffer.clear st.notices; + Ok (mk_reply st ~pre ~tag:(Goals.focus_tag ()) + (Text (FrameTree.render ~all ()))) + +let commit (st : state) = + let pre = EcCommands.uuid () in + Buffer.clear st.notices; + Ok (mk_reply st ~pre ~tag:(Goals.focus_tag ()) + (Text (Commit.proof_text st))) + +let undo (st : state) = + let pre = EcCommands.uuid () in + Buffer.clear st.notices; + let uuid = EcCommands.uuid () in + if uuid > 0 then begin + EcCommands.undo (uuid - 1); + Transcript.trim st (uuid - 1); + (* Somewhere definite: the stop has been answered. *) + Strict.clear st; + Ok (mk_reply_goals st ~pre) + end else + Error (mk_failure st ~pre "nothing to undo") + +let focus (st : state) request = + (* [request] is the user's intent normalized: + - [`Next] = rotate to the second open goal (or stay if <=1) + - [`Path p] = resolve dotted path [p] against the frame tree + and focus the matching leaf. *) + let pre = EcCommands.uuid () in + Buffer.clear st.notices; + if Strict.stopped st then Error (Strict.refuse st ~pre) else + let resolved = + match request with + | `Next -> + let n = List.length (EcCommands.open_handles ()) in + Ok (if n <= 1 then 1 else 2) + | `Path path -> FrameTree.resolve_path path + in + match resolved with + | Error msg -> Error (mk_failure st ~pre msg) + | Ok target -> + match EcCommands.focus_goal target with + | Ok _ -> Ok (mk_reply_goals st ~pre) + | Error msg -> Error (mk_failure st ~pre msg) + +let checkpoint (st : state) ~name = + let pre = EcCommands.uuid () in + Buffer.clear st.notices; + Hashtbl.replace st.checkpoints name (EcCommands.uuid ()); + Ok (mk_reply st ~pre (Text (Printf.sprintf + "checkpoint '%s' set at uuid %d" name (EcCommands.uuid ())))) + +(* Strict mode on and off. Turning it off releases a stop: a session + that does not stop at failures cannot be sitting at one. *) +let strict (st : state) ~(on : bool) = + let pre = EcCommands.uuid () in + Buffer.clear st.notices; + st.strict_mode := on; + if not on then Strict.clear st; + Ok (mk_reply st ~pre (Text ( + if on then + "strict: on -- a failure stops the session until UNDO, REVERT, \ + LOAD or RESUME" + else + "strict: off"))) + +(* Release a stop without going anywhere: the client has read the + failure and means to carry on from where it left the engine. The + two refusals are not pedantry -- a client that resumes a session + that was never stopped has lost track of it, which is the one thing + this mode exists to tell it. *) +let resume (st : state) = + let pre = EcCommands.uuid () in + Buffer.clear st.notices; + if not !(st.strict_mode) then + Error (mk_failure st ~pre "RESUME: strict mode is off") + else if !(st.stopped_at) = None then + Error (mk_failure st ~pre "RESUME: the session is not stopped") + else begin + Strict.clear st; + Ok (mk_reply_goals st ~pre) + end + +let revert (st : state) spec = + let pre = EcCommands.uuid () in + Buffer.clear st.notices; + let target = + try Some (int_of_string spec) + with Failure _ -> Hashtbl.find_opt st.checkpoints spec + in + match target with + | None -> + Error (mk_failure st ~pre (Printf.sprintf + "REVERT: '%s' is not a valid uuid or checkpoint name" spec)) + | Some target -> + let uuid = EcCommands.uuid () in + if target < 0 || target > uuid then + Error (mk_failure st ~pre (Printf.sprintf + "REVERT: uuid %d out of range [0, %d]" target uuid)) + else begin + EcCommands.undo target; + Transcript.trim st target; + Strict.clear st; + Ok (mk_reply_goals st ~pre) + end + +(* SEARCH is handed a search pattern, not EasyCrypt input. Composing + ["search " ^ pattern ^ "."] and running it through [step] made every + sentence-ending '.' inside the pattern a statement separator, so a + pattern like [(_ /\ _). split. admit] executed [split] and [admit] + too. Parse the composed phrase here instead, and run it only if it + is exactly one toplevel item whose action is a [search]: a pattern + that closes the sentence on its own leaves trailing input, which + this rejects. Screening the pattern for '.' would be wrong -- + qualified names (A.B.lem) are legitimate patterns. *) +let search (st : state) ~pattern = + let pre = EcCommands.uuid () in + Buffer.clear st.notices; + let src = Printf.sprintf "search %s." pattern in + let reject = "SEARCH: the argument must be a single search pattern" in + let is_search (p : EP.global) = + not p.EP.gl_fail + && match EcLocation.unloc p.EP.gl_action with + | EP.Gsearch _ -> true + | _ -> false + in + let parsed = + let reader = EcIo.from_string src in + let next () = + match EcIo.xparse reader with + | exception End_of_file -> `End + | (_, prog) -> + match EcLocation.unloc prog with + | EP.P_Prog ([ ], true ) -> `End + | EP.P_Prog ([p], false) -> `Item p + | _ -> `Other + in + let result = + try + match next () with + | `Item p when is_search p -> + (match next () with + | `End -> Ok p + | `Item _ | `Other -> Error reject) + | `Item _ | `Other | `End -> Error reject + with e -> Error (Goals.format_error e) + in + EcIo.finalize reader; result + in + match parsed with + | Error msg -> Error (mk_failure st ~pre msg) + | Ok p -> + match process_action st ~src p with + | () -> Ok (mk_reply_goals st ~pre) + | exception e -> Error (mk_failure st ~pre (Goals.format_error ~src e)) diff --git a/src/ecLlmCore.mli b/src/ecLlmCore.mli new file mode 100644 index 000000000..11d7fbc04 --- /dev/null +++ b/src/ecLlmCore.mli @@ -0,0 +1,180 @@ +(* -------------------------------------------------------------------- *) +(* Engine-facing core of the LLM interaction protocol: one operation per + meta-command of the [easycrypt llm] REPL, with the text protocol + factored out. Operations never print, never exit, and never format a + wire envelope; they return structured values a front-end renders + (the REPL in [ecLlm.ml], the MCP server next to it). + + One process = one session: the proof engine ([EcCommands]) is global + mutable state, so at most one [state] may exist per process. *) + +(* -------------------------------------------------------------------- *) +type state + +(* Reply body. [Goals] means "the current goals"; the front-end renders + them through [current_goals] and may suppress them (the REPL does, + under QUIET). [Text] is a literal body and is never suppressed. *) +type body = + | Goals + | Text of string + +(* [notices] are the engine messages emitted while the operation ran, + captured and cleared at the point the REPL used to print them. + [changed] tells whether the engine uuid advanced. *) +type reply = { + uuid : int; + tag : string; + notices : string; + body : body; + changed : bool; +} + +(* [goals] is the goal state at the point of failure. The REPL does not + render [notices] on failures (it never did); they are captured all + the same, so the buffer is left clean for the next operation. + [reverted] is set by [try_step] only: it says the engine was rolled + back to the state it had before the operation ran, so [uuid] and + [goals] describe that restored state, not the point of failure. + [changed] tells whether the engine uuid moved -- a failing operation + may well have moved it before failing. It reports the *net* effect + of the call, so under [try_step] it is always [false]: the rollback + is exact, and after it there is nothing left to have changed. *) +type failure = { + uuid : int; + message : string; + goals : string; + notices : string; + reverted : bool; + changed : bool; +} + +(* Operations that can be asked to end the session ([exit.]) return an + [answer]: the front-end owns the process, hence the exit. *) +type answer = + | Done of (reply, failure) result + | Quit + +(* Raised by [create] when the session cannot be set up. *) +exception Init_error of string + +(* -------------------------------------------------------------------- *) +(* Open a session: connect to the Why3 server, seed the loader with + [relocdir], and initialize the engine. [projini] resolves the + [easycrypt.project] context of a file path, so [load] can apply the + project's load path and prover options the way the batch compiler + does. *) +val create : + relocdir:string option + -> boot:bool + -> projini:(string option -> EcOptions.ini_context option) + -> prvopts:EcOptions.prv_options + -> state + +(* -------------------------------------------------------------------- *) +(* Operations. *) + +(* LOAD, on already-parsed arguments. + + [noproof] reads the prefix the way a [require]d file is read: proof + checking off, so every lemma is admitted on its statement and its + script is skipped whole -- not even typed. The proof [upto] points + inside, if any, is the exception; checking goes back on for it, so + the goal state LOAD reports is the real one. The reply is tagged + [[noproof]] whenever proofs were skipped, a successful load of an + unverified prefix being no evidence about the file. *) +val load : + state + -> file:string + -> upto:(int * int option) option + -> nosmt:bool + -> noproof:bool + -> trace:bool + -> (reply, failure) result + +(* Raw EasyCrypt input: one line, or a multi-line block. Every + sentence the input holds is executed, in order, and a single reply + describes the state they leave behind. A sentence that fails stops + the run at that point and its failure is returned; the sentences + before it stay applied, as they would in a compiled file. An + [exit.] ends the session immediately, with the sentences that + preceded it applied. *) +val step : state -> string -> answer + +(* [step], but a failure leaves no trace: the observable session -- + uuid, goals, COMMIT transcript -- is put back exactly as it was on + entry, and the failure comes back with [reverted = true] and + [changed = false]. Successes and [Quit] behave exactly as in [step]. + Input that fails after having already moved the engine is rolled + back whole, and "moved" includes moving *down*: a phrase whose first + sentence is [undo 3.] is restored just as faithfully as one that + advanced. *) +val try_step : state -> string -> answer + +(* Strict mode. Off, a session behaves as a file does: a failure is + reported and the next input runs against wherever it left the + engine. On, the session stops at any failure of an operation that + could have advanced, and every operation that could advance it + further is refused until the session is resynchronized -- by + [undo], [revert] or [load], which arrive somewhere definite, or by + [resume], which says so. Reads are never refused: [goals], [tree], [search], [checkpoint] + and [commit] answer while stopped, the point being to look at the + failure rather than be locked out of it. + + It is the client sending one phrase per call that this is for. Such + a client keeps sending after a failure, and each phrase runs against + a state it was not written for -- the one the failed phrase was + meant to leave, and never reached. The drift is silent and is + noticed much later. [try_step]'s failures do not arm the stop, since + they restore the state the call started from and report having done + so, but it is refused while stopped like anything else that would + advance. + + [resume] fails if the session is not stopped, or if strict mode is + off: a client resuming a session that was never stopped has lost + track of it, which is what this mode is here to say. *) +val strict : state -> on:bool -> (reply, failure) result +val resume : state -> (reply, failure) result + +val goals : state -> all:bool -> (reply, failure) result +val tree : state -> all:bool -> (reply, failure) result +val focus : state -> [`Next | `Path of int list] -> (reply, failure) result +val undo : state -> (reply, failure) result +val revert : state -> string -> (reply, failure) result +val checkpoint : state -> name:string -> (reply, failure) result +val commit : state -> (reply, failure) result + +(* SEARCH. [pattern] is a search pattern, not EasyCrypt input: the + composed phrase is parsed here and refused unless it is exactly one + toplevel [search] item, so a pattern carrying a sentence-ending '.' + cannot smuggle further commands past it. Hence a [result] and not an + [answer]: SEARCH runs a query and can never end the session. *) +val search : state -> pattern:string -> (reply, failure) result + +(* -------------------------------------------------------------------- *) +(* Front-end helpers: for replies a front-end produces on its own (the + REPL's HELP and QUIET) and for errors it detects itself (line-parse + errors). Both capture-and-clear the notice buffer, as the operations + above do. *) + +val uuid : state -> int +val current_goals : state -> string +val clear_notices : state -> unit +val make_reply : state -> ?tag:string -> body -> reply +val make_failure : state -> string -> failure + +(* -------------------------------------------------------------------- *) +(* Argument checks the two front-ends share. What a command accepts, and + what it says when it refuses, is the same at the prompt as over MCP; + only the way a rejection travels -- a line-parse error there, a + JSON-RPC error or an [isError] result here -- is the front-end's, so + these return the message rather than raising. *) + +(* A dotted goal path, as FOCUS accepts it: "2", "1.2.1". [what] is the + command's name as the calling front-end spells it ("FOCUS" at the + prompt, "ec_focus" over MCP), and opens the error message. *) +val parse_goal_path : what:string -> string -> (int list, string) result + +(* Reject a LOAD path naming no file. [load] itself does not check: + it resets the session before opening the file, so the check has to + happen in the front-end, before the call. *) +val check_load_file : string -> (unit, string) result diff --git a/src/ecLoader.ml b/src/ecLoader.ml index b52f663c2..b3ae2df91 100644 --- a/src/ecLoader.ml +++ b/src/ecLoader.ml @@ -38,6 +38,10 @@ let create () = { ecl_idirs = []; } let aslist (ld : ecloader) = ld.ecl_idirs +(* -------------------------------------------------------------------- *) +let setidirs (idirs : ((namespace option * string) * idx_t) list) (ld : ecloader) = + ld.ecl_idirs <- idirs + (* -------------------------------------------------------------------- *) let dup (ld : ecloader) = { ecl_idirs = ld.ecl_idirs; } diff --git a/src/ecLoader.mli b/src/ecLoader.mli index 2338f6ad7..6ed7747f8 100644 --- a/src/ecLoader.mli +++ b/src/ecLoader.mli @@ -20,4 +20,9 @@ val aslist : ecloader -> ((namespace option * string) * idx_t) list val dup : ecloader -> ecloader val forsys : ecloader -> ecloader val addidir : ?namespace:namespace -> ?recursive:bool -> string -> ecloader -> unit + +(* Replace the include path wholesale, [aslist] being its reader. + [addidir] only ever grows the path, so this is what lets a caller + come back to an earlier one. *) +val setidirs : ((namespace option * string) * idx_t) list -> ecloader -> unit val locate : ?namespaces:(namespace option) list -> string -> ecloader -> (namespace option * string * kind) option diff --git a/src/ecMcp.ml b/src/ecMcp.ml new file mode 100644 index 000000000..21d8d6be1 --- /dev/null +++ b/src/ecMcp.ml @@ -0,0 +1,951 @@ +(* -------------------------------------------------------------------- *) +(* The Model Context Protocol front-end. See [ecMcp.mli]. + + This module is to MCP what [EcLlm] is to the text protocol: a wire + layer only. Every engine-facing operation goes through [EcLlmCore], + which the two front-ends share. + + The loop is synchronous and single-threaded, which is not an + implementation shortcut but the correctness anchor: the proof engine + is a global mutable singleton and uuid ordering is what makes + [ec_revert] meaningful, so tool calls must run strictly in arrival + order even when a client pipelines them. Several agents behind one + client get one such process each from [EcMcpMux] ([mcp -sessions]), + which forwards to children running this very loop. *) + +module J = Yojson.Safe + +(* -------------------------------------------------------------------- *) +(* Protocol revisions. + + We speak the handshake-based ("legacy", in the vocabulary of the + 2026-07-28 spec) era: [initialize] / [notifications/initialized], + with the negotiated version fixed for the life of the process. Every + deployed client speaks it. + + Revision 2026-07-28 replaced the handshake with per-request [_meta] + and a mandatory [server/discover]; supporting it is a separate piece + of work. A dual-era client probes with [server/discover], gets our + [-32601] -- not a recognized modern error -- and falls back to + [initialize], which is exactly the intended detection path. *) +let protocol_latest = "2025-11-25" + +let protocol_supported = [ + "2025-11-25"; + "2025-06-18"; + "2025-03-26"; +] + +let server_name = "easycrypt" + +let server_version = + match EcVersion.hash with "n/a" -> "dev" | v -> v + +(* -------------------------------------------------------------------- *) +(* JSON-RPC 2.0 error codes. *) +let e_parse_error = -32700 +let e_invalid_request = -32600 +let e_method_not_found = -32601 +let e_invalid_params = -32602 + +(* Raised by argument validation: a malformed [tools/call] is a + *protocol* failure, and must not be dressed up as a prover error. *) +exception Invalid_params of string + +(* Raised by the checks a tool performs on its own behalf before + reaching the engine (a missing file, say). Those are EasyCrypt-level + failures and travel as successful responses with [isError]. *) +exception Tool_error of string + +(* -------------------------------------------------------------------- *) +(* [-help]. Where [llm -help] prints the whole agent guide, we print the + one section of it that describes this server: from its heading down + to the next heading of the same level. A guide in which that heading + cannot be found is printed whole, rather than not at all. *) +let usage_section = "## Using the MCP mode" + +let extract_usage (guide : string) = + let is_heading line = + String.length line >= 3 && String.sub line 0 3 = "## " in + let rec seek = function + | [] -> None + | line :: rest when String.trim line = usage_section -> + Some (line :: keep rest) + | _ :: rest -> seek rest + and keep = function + | [] -> [] + | line :: _ when is_heading line -> [] + | line :: rest -> line :: keep rest + in + match seek (String.split_on_char '\n' guide) with + | None -> guide + | Some lines -> String.concat "\n" lines + +let print_usage () = + let path = EcLlm.llm_guide_path () in + try + let ic = open_in_bin path in + let guide = really_input_string ic (in_channel_length ic) in + close_in ic; + print_string (extract_usage guide) + with Sys_error e -> + Printf.eprintf "cannot read LLM guide: %s\n%!" e + +(* -------------------------------------------------------------------- *) +(* UTF-8 repair. + + A JSON string is UTF-8 by definition, and OCaml strings are bytes. + Reply text is engine output, which is not ours to trust: EasyCrypt + echoes source text verbatim (a traced sentence, an error message + quoting its input), so one Latin-1 comment in a loaded file is + enough to put a raw 0xe9 inside a JSON string and make the whole + response line unparseable. Every invalid byte is replaced by U+FFFD + on the way out; a message that is already valid UTF-8 is returned + unchanged, allocating nothing. *) + +(* Length of the well-formed UTF-8 sequence starting at [i], or 0. The + bounds are the Unicode standard's: no overlong encodings, no + surrogates, nothing past U+10FFFF. *) +let utf8_width (s : string) (i : int) = + let n = String.length s in + let byte k = Char.code (String.unsafe_get s k) in + let cont k = k < n && byte k land 0xc0 = 0x80 in + let b0 = byte i in + if b0 < 0x80 then 1 + else if b0 < 0xc2 then 0 (* stray continuation, or overlong *) + else if b0 <= 0xdf then + (if cont (i + 1) then 2 else 0) + else if b0 <= 0xef then + let lo = if b0 = 0xe0 then 0xa0 else 0x80 in + let hi = if b0 = 0xed then 0x9f else 0xbf in + if i + 2 < n && byte (i + 1) >= lo && byte (i + 1) <= hi && cont (i + 2) + then 3 else 0 + else if b0 <= 0xf4 then + let lo = if b0 = 0xf0 then 0x90 else 0x80 in + let hi = if b0 = 0xf4 then 0x8f else 0xbf in + if i + 3 < n && byte (i + 1) >= lo && byte (i + 1) <= hi + && cont (i + 2) && cont (i + 3) + then 4 else 0 + else 0 + +let utf8_repair (s : string) = + let n = String.length s in + let rec valid i = + i >= n || (let k = utf8_width s i in k > 0 && valid (i + k)) + in + if valid 0 then s + else begin + let buf = Buffer.create (n + 8) in + let rec copy i = + if i < n then + match utf8_width s i with + | 0 -> Buffer.add_string buf "\xef\xbf\xbd"; copy (i + 1) + | k -> Buffer.add_substring buf s i k; copy (i + k) + in + copy 0; Buffer.contents buf + end + +(* -------------------------------------------------------------------- *) +(* The wire: one JSON value per line, flushed at once. Yojson escapes + newlines inside strings, so a message never contains one, as the + stdio transport requires. + + The encoding is shared with the session multiplexer ([EcMcpMux]), + which is why it lives at module level: [Over] instantiates the reply + helpers over whatever [send] the caller has -- a bare channel here, + a mutex-guarded one there. *) +module Wire = struct + (* Repair every string in the message rather than the reply text + alone: this is the one point every byte leaves through, so no + future tool or error path can put invalid UTF-8 on the wire by + forgetting to sanitize. *) + (* Only the constructors we build are named: [Tuple] and [Variant] + are non-standard extensions we never emit, and yojson 3 dropped + them from the type, so naming them here would not compile there. *) + let rec repair (msg : J.t) : J.t = + match msg with + | `String s -> `String (utf8_repair s) + | `List l -> `List (List.map repair l) + | `Assoc l -> `Assoc (List.map (fun (k, v) -> (k, repair v)) l) + | msg -> msg + + let writer (oc : out_channel) (msg : J.t) = + output_string oc (J.to_string (repair msg)); + output_char oc '\n'; + flush oc + + let result_msg id (result : J.t) : J.t = + `Assoc [ + ("jsonrpc", `String "2.0"); + ("id", id); + ("result", result); + ] + + let error_msg ?data id code message : J.t = + `Assoc [ + ("jsonrpc", `String "2.0"); + ("id", id); + ("error", `Assoc ([ + ("code", `Int code); + ("message", `String message); + ] @ (match data with None -> [] | Some d -> [("data", d)]))); + ] + + module Over (C : sig val send : J.t -> unit end) = struct + let send = C.send + let result id result = send (result_msg id result) + let error ?data id code message = send (error_msg ?data id code message) + end +end + +(* stdout carries the protocol and nothing else. Rather than trust + every code path under the engine to stay silent, keep a private + descriptor for the protocol and point the process's stdout at + stderr, so a stray [print_string] anywhere lands in the client's + log instead of corrupting the message stream. The descriptor is + close-on-exec: no process we start (a prover, a session engine) + has any business holding the client's pipe. *) +let wire_stdout () = + let fd = Unix.dup ~cloexec:true Unix.stdout in + Unix.dup2 Unix.stderr Unix.stdout; + Unix.out_channel_of_descr fd + +(* -------------------------------------------------------------------- *) +(* JSON schema fragments for the tool declarations. *) +module Schema = struct + let str ?description () = + `Assoc (("type", `String "string") + :: (match description with + | None -> [] + | Some d -> [("description", `String d)])) + + let int ~description () = + `Assoc [("type", `String "integer"); + ("description", `String description)] + + (* [default] is omitted for a required property: a schema that + declares one and demands the property anyway says two things at + once, and a client is entitled to believe either. *) + let bool ~description ?default () = + `Assoc ([("type", `String "boolean"); + ("description", `String description)] + @ (match default with + | None -> [] + | Some d -> [("default", `Bool d)])) + + let obj ?(required = []) props = + `Assoc ([("type", `String "object"); + ("properties", `Assoc props)] + @ (match required with + | [] -> [] + | _ -> [("required", + `List (List.map (fun s -> `String s) required))]) + @ [("additionalProperties", `Bool false)]) + + (* Every tool answers with the same structured payload: the reply + text, the engine state the call left behind, and whether it moved. + + [text] repeats [content[0].text] verbatim. The duplication is + deliberate: Claude Code, our primary client, hands the model the + [structuredContent] object alone and drops [content] whenever both + are present, so a payload that lives only in [content] never + reaches the agent. See tests/mcp/README.md. *) + let output ?(reverted = false) () = + let base = [ + ("text", str ~description:"the reply body -- goal state, proof \ + body, search results, error text; the \ + same string as content[0].text" ()); + ("uuid", int ~description:"engine state identifier after the call; \ + pass it to ec_revert to come back here" ()); + ("changed", `Assoc [("type", `String "boolean"); + ("description", + `String "whether the engine state advanced")]); + ] in + let base = + if not reverted then base + else base @ [ + ("reverted", + `Assoc [("type", `String "boolean"); + ("description", + `String "set when the phrase failed and the engine was \ + rolled back to its pre-call state")]); + ] + in + `Assoc [("type", `String "object"); + ("properties", `Assoc base); + ("required", `List [`String "text"; `String "uuid"; + `String "changed"])] +end + +(* -------------------------------------------------------------------- *) +(* The static tool table, in [tools/list] order. Descriptions are + agent-facing and track the wording of doc/llm/CLAUDE.md. *) +let tools : J.t list = + let tool ~name ~description ~input ?(annotations = []) ~output () = + `Assoc ([ + ("name", `String name); + ("description", `String description); + ("inputSchema", input); + ("outputSchema", output); + ] @ (match annotations with + | [] -> [] + | _ -> [("annotations", `Assoc annotations)])) + in [ + tool + ~name:"ec_load" + ~description: + "Reset the session and compile FILE from the top, stopping after \ + the last sentence that ends on or before LINE (and column COL \ + when given). This is the entry point: every other tool needs a \ + loaded file, and tactics need the position to land inside a \ + proof. Set nosmt to weaken SMT calls while replaying a prefix \ + that was already verified, which is much faster on large files. \ + Set noproof to go further and skip the prefix's proofs \ + altogether, admitting every lemma before the target on its \ + statement alone -- only the proof the position lands inside is \ + replayed, which is the fastest way into a proof in a long file. \ + Set trace to have the reply describe the last loaded sentence as \ + BEFORE / TACTIC / AFTER / SUMMARY blocks. The reply reports \ + where compilation stopped and the resulting goal state; note the \ + uuid it returns, reverting to it is the instant way back to the \ + start of the proof." + ~input:(Schema.obj ~required:["file"] [ + ("file", Schema.str ~description:"path to the .ec/.eca file" ()); + ("line", Schema.int + ~description:"stop after the last sentence ending on \ + or before this line; omit to compile the \ + whole file" ()); + ("col", Schema.int + ~description:"column bound within `line'; requires \ + `line'" ()); + ("nosmt", Schema.bool + ~description:"weaken SMT calls while compiling the \ + prefix" ~default:false ()); + ("noproof", Schema.bool + ~description:"skip the prefix's proofs entirely, \ + admitting the lemmas before the \ + target as axioms" ~default:false ()); + ("trace", Schema.bool + ~description:"report the proof state around the last \ + loaded sentence" ~default:false ()); + ]) + ~annotations:[("destructiveHint", `Bool true)] + ~output:(Schema.output ()) + (); + + tool + ~name:"ec_step" + ~description: + "Run EasyCrypt sentences -- tactics, declarations, require, \ + print, ... -- against the current session. Every complete \ + sentence in the argument is executed, in order, exactly as if \ + the text had been appended to the source file, and a single \ + reply describes the state they leave behind; sentences may \ + span several lines. Requires a file loaded with ec_load, and, \ + for tactics, an open proof. On success the reply carries the \ + new goal state; on failure the prover's error text comes back \ + with isError set, the sentences before the failing one stay \ + applied and the engine is left wherever that sentence left it \ + -- use ec_try when you want a guaranteed rollback. Successful \ + non-query phrases are recorded for ec_commit." + ~input:(Schema.obj ~required:["phrase"] [ + ("phrase", Schema.str + ~description:"one or more complete EasyCrypt \ + sentences, each ending with `.'" ()); + ]) + ~annotations:[("destructiveHint", `Bool false); + ("idempotentHint", `Bool false)] + ~output:(Schema.output ()) + (); + + tool + ~name:"ec_try" + ~description: + "Like ec_step, but the engine is rolled back to the state it had \ + before the call whenever a sentence fails, including input that \ + failed only after having already advanced the proof. The \ + failure reply sets structuredContent.reverted to true, and its \ + uuid and goal text describe the restored state, not the point \ + of failure. Use this to probe a tactic without having to \ + ec_revert afterwards; use ec_step when you mean to keep \ + whatever progress the phrase makes. A successful phrase behaves \ + exactly as under ec_step and is recorded for ec_commit." + ~input:(Schema.obj ~required:["phrase"] [ + ("phrase", Schema.str + ~description:"one complete EasyCrypt sentence, \ + ending with `.'" ()); + ]) + ~annotations:[("destructiveHint", `Bool false)] + ~output:(Schema.output ~reverted:true ()) + (); + + tool + ~name:"ec_goals" + ~description: + "Print the current proof state: the focused subgoal alone, or, \ + with all set, every open subgoal. Requires an open proof, and \ + does not advance the engine." + ~input:(Schema.obj [ + ("all", Schema.bool + ~description:"print every open subgoal instead of the \ + focused one" ~default:false ()); + ]) + ~annotations:[("readOnlyHint", `Bool true)] + ~output:(Schema.output ()) + (); + + tool + ~name:"ec_tree" + ~description: + "List the open subgoals as a tree of dotted-path labels -- [1], \ + [1.2], [2.1.1] -- showing how the splits nest, and marking the \ + focused one. Those labels are exactly what ec_focus accepts. \ + Set full for whole goal bodies rather than one-line \ + conclusions. The labels are not stable across focus changes: \ + the tree always shows the focused goal first, so re-read it \ + after every ec_focus. Does not advance the engine." + ~input:(Schema.obj [ + ("full", Schema.bool + ~description:"print full goal bodies instead of \ + one-line conclusions" ~default:false ()); + ]) + ~annotations:[("readOnlyHint", `Bool true)] + ~output:(Schema.output ()) + (); + + tool + ~name:"ec_focus" + ~description: + "Rotate the focus onto the subgoal at dotted path PATH, as \ + printed by ec_tree (\"2\", \"1.2\", \"1.1.1\"). The path walks \ + the tree, one component per level, so a single integer selects \ + the k-th TOP-LEVEL node -- not the k-th open goal: with four \ + goals nested under two top-level nodes, \"3\" is out of range. \ + Selecting a node that is an internal frame rather than a leaf \ + goal is an error. The special value \"next\" is a different \ + operation, not a synonym for \"2\": it moves to the next open \ + subgoal in ec_goals-with-all order, whatever the nesting, and \ + the two coincide only when the tree is flat. Subsequent \ + tactics act on the focused goal." + ~input:(Schema.obj ~required:["path"] [ + ("path", Schema.str + ~description:"\"N\", a dotted path \"N1.N2...\", or \ + \"next\"" ()); + ]) + ~annotations:[("destructiveHint", `Bool false)] + ~output:(Schema.output ()) + (); + + tool + ~name:"ec_undo" + ~description: + "Undo the last engine step, returning to the immediately \ + preceding state. The ec_commit transcript is trimmed to match. \ + Fails when there is nothing left to undo." + ~input:(Schema.obj []) + ~annotations:[("destructiveHint", `Bool false)] + ~output:(Schema.output ()) + (); + + tool + ~name:"ec_revert" + ~description: + "Return the session to an earlier state, named either by a uuid \ + reported in some previous structuredContent or by a name given \ + to ec_checkpoint. Reverting is instant, unlike re-running \ + ec_load, so going back to the uuid ec_load returned is the cheap \ + way to restart a proof from scratch after a failed experiment. \ + The ec_commit transcript is trimmed to match." + ~input:(Schema.obj ~required:["target"] [ + ("target", Schema.str + ~description:"a uuid (as a decimal string) or a \ + checkpoint name" ()); + ]) + ~annotations:[("destructiveHint", `Bool true)] + ~output:(Schema.output ()) + (); + + tool + ~name:"ec_checkpoint" + ~description: + "Record the current uuid under NAME, so that ec_revert can \ + address it by name later. Worth doing before a branching \ + experiment, when carrying the bare uuid around is awkward. Does \ + not change the proof state." + ~input:(Schema.obj ~required:["name"] [ + ("name", Schema.str ~description:"checkpoint name" ()); + ]) + ~annotations:[("readOnlyHint", `Bool true)] + ~output:(Schema.output ()) + (); + + tool + ~name:"ec_commit" + ~description: + "Emit the phrases recorded since the last ec_load as a proof \ + body, with bullets inserted at every multi-child split: the \ + result compiles under `pragma +strict_bullets' and can be \ + pasted straight into the source file. Queries (search, print, \ + locate, ec_search) are never recorded, so looking things up \ + mid-proof does not pollute the body, and ec_undo / ec_revert \ + trim the transcript. Still works after `qed.'. Does not change \ + the proof state." + ~input:(Schema.obj []) + ~annotations:[("readOnlyHint", `Bool true)] + ~output:(Schema.output ()) + (); + + tool + ~name:"ec_strict" + ~description: + "Turn strict mode on or off. Off (the default) a session \ + behaves as a source file does: a failing phrase is reported \ + and the next call runs against wherever it left the engine. \ + On, the session stops at a failure that may have moved the \ + engine, and ec_step, ec_try and ec_focus are refused until it \ + is resynchronized -- by ec_undo, ec_revert or ec_load, which \ + arrive somewhere definite, or by ec_resume, which says so. \ + Turn it on if you send one phrase per call and act on each \ + result: without it a failure is followed by calls landing on \ + a state you did not mean, and the drift is silent. Reads \ + (ec_goals, ec_tree, ec_search, ec_checkpoint, ec_commit) \ + always answer, stopped or not." + ~input:(Schema.obj ~required:["on"] [ + ("on", Schema.bool + ~description:"true to stop the session at a failure" ()); + ]) + ~annotations:[("destructiveHint", `Bool false)] + ~output:(Schema.output ()) + (); + + tool + ~name:"ec_resume" + ~description: + "Release a strict-mode stop without moving the engine: you have \ + read the failure and mean to carry on from where it left the \ + session. Use ec_undo or ec_revert instead when you would \ + rather go back. Fails when the session is not stopped, or when \ + strict mode is off -- either way you are not where you think \ + you are, which is what strict mode is for." + ~input:(Schema.obj []) + ~annotations:[("destructiveHint", `Bool false)] + ~output:(Schema.output ()) + (); + + tool + ~name:"ec_search" + ~description: + "Search the environment for lemmas matching an EasyCrypt search \ + pattern. This is pattern syntax, not keyword search: use _ as \ + the wildcard, as in \"(fdom _)\", \"(_ %/ _)\" or \"(mu _ _) (_ \ + <= _)\". Requires a loaded file. The query neither advances the \ + proof nor enters the ec_commit transcript." + ~input:(Schema.obj ~required:["pattern"] [ + ("pattern", Schema.str + ~description:"an EasyCrypt search pattern" ()); + ]) + ~annotations:[("readOnlyHint", `Bool true)] + ~output:(Schema.output ()) + (); + ] + +(* -------------------------------------------------------------------- *) +(* The same table as the session multiplexer advertises it: every tool + takes a required [session] naming the engine the call runs in. A + pure function of [tools], kept next to it so that the two cannot + drift apart. *) +let session_property : J.t = + Schema.str + ~description:"the name of YOUR engine session (e.g. your agent tag); \ + the first call creates it, later calls reuse it. Never \ + use another agent's name." () + +let session_suffix = + " [Runs in the engine of the `session' you name; sessions are \ + independent.]" + +let tools_with_session : J.t list = + let add_session (tool : J.t) : J.t = + match tool with + | `Assoc fields -> + let fields = + List.map (fun (k, v) -> + match k, v with + | "description", `String d -> + (k, `String (d ^ session_suffix)) + | "inputSchema", `Assoc schema -> + let props = + match List.assoc_opt "properties" schema with + | Some (`Assoc props) -> props + | _ -> [] + and required = + match List.assoc_opt "required" schema with + | Some (`List l) -> l + | _ -> [] + in + let schema = + List.filter (fun (k, _) -> k <> "properties" && k <> "required") + schema + in + let schema = + (* Keep the field order of [Schema.obj]: type, properties, + required, additionalProperties. *) + let head, tail = + List.partition (fun (k, _) -> k = "type") schema in + head + @ [("properties", `Assoc (props @ [("session", session_property)])); + ("required", `List (required @ [`String "session"]))] + @ tail + in + (k, `Assoc schema) + | _ -> (k, v)) + fields + in + `Assoc fields + | tool -> tool + in + List.map add_session tools + +(* -------------------------------------------------------------------- *) +(* Argument access. Everything here reports through [Invalid_params]: + these are failures to satisfy the declared input schema, which the + spec classifies as protocol errors, not tool-execution errors. *) +module Args = struct + let of_params (params : J.t option) = + match params with + | None | Some `Null -> [] + | Some (`Assoc fields) -> fields + | Some _ -> raise (Invalid_params "`params' must be an object") + + let arguments (params : J.t option) = + match List.assoc_opt "arguments" (of_params params) with + | None | Some `Null -> [] + | Some (`Assoc fields) -> fields + | Some _ -> raise (Invalid_params "`arguments' must be an object") + + let bad tool name expected = + raise (Invalid_params + (Printf.sprintf "%s: `%s' must be %s" tool name expected)) + + let string_req tool args name = + match List.assoc_opt name args with + | Some (`String s) -> s + | Some _ -> bad tool name "a string" + | None -> + raise (Invalid_params + (Printf.sprintf "%s: missing required argument `%s'" tool name)) + + let bool_opt tool args name ~default = + match List.assoc_opt name args with + | None | Some `Null -> default + | Some (`Bool b) -> b + | Some _ -> bad tool name "a boolean" + + let bool_req tool args name = + match List.assoc_opt name args with + | Some (`Bool b) -> b + | Some _ -> bad tool name "a boolean" + | None -> + raise (Invalid_params + (Printf.sprintf "%s: missing required argument `%s'" tool name)) + + let int_opt tool args name = + match List.assoc_opt name args with + | None | Some `Null -> None + | Some (`Int i) -> Some i + | Some _ -> bad tool name "an integer" +end + +(* The [initialize] result. Spec: answer with the requested version + when we speak it, otherwise with the latest one we do speak. *) +let initialize_result (params : J.t option) : J.t = + let requested = + match List.assoc_opt "protocolVersion" (Args.of_params params) with + | Some (`String v) -> Some v + | _ -> None + in + let negotiated = + match requested with + | Some v when List.mem v protocol_supported -> v + | _ -> protocol_latest + in + `Assoc [ + ("protocolVersion", `String negotiated); + ("capabilities", `Assoc [("tools", `Assoc [])]); + ("serverInfo", `Assoc [ + ("name", `String server_name); + ("version", `String server_version); + ]); + ] + +(* The [ec_focus] path is a string in the schema, so its shape is ours + to check: "next", or a dotted sequence of positive integers. Only + "next" is MCP's own -- the REPL spells it as a separate command -- + so the path itself goes through the shared parser. *) +let focus_target (arg : string) = + if String.lowercase_ascii arg = "next" then `Next + else + match EcLlmCore.parse_goal_path ~what:"ec_focus" arg with + | Ok path -> `Path path + | Error msg -> raise (Invalid_params msg) + +(* -------------------------------------------------------------------- *) +let run ~relocdir ~boot ~projini (mcpopts : EcOptions.mcp_option) = + if mcpopts.mcpo_help then begin + print_usage (); + exit 0 + end; + + let wire = wire_stdout () in + + let prvopts = mcpopts.mcpo_provers in + + let st = + try EcLlmCore.create ~relocdir ~boot ~projini ~prvopts + with EcLlmCore.Init_error msg -> + Printf.eprintf "%s\n%!" msg; + exit 1 + in + + let module Wire = Wire.Over (struct let send = Wire.writer wire end) in + + (* ------------------------------------------------------------------ *) + (* Rendering [EcLlmCore] outcomes as tool results. *) + let module Result_of = struct + let content text = + `List [`Assoc [("type", `String "text"); ("text", `String text)]] + + (* [text] appears twice, once in each half of the result, and the + two copies are the same string by construction. Clients that read + [content] are served by the first; Claude Code, which drops + [content] as soon as [structuredContent] is present, is served + only by the second. *) + let make ~text ~uuid ~changed ~is_error ~extra = + `Assoc [ + ("content", content text); + ("structuredContent", + `Assoc ([("text", `String text); + ("uuid", `Int uuid); + ("changed", `Bool changed)] @ extra)); + ("isError", `Bool is_error); + ] + + (* The notice buffer holds whatever the engine said while the + operation ran; it precedes the body, as it does in the REPL. *) + let join notices body = + if notices = "" then body + else if body = "" then notices + else if String.length notices > 0 + && notices.[String.length notices - 1] = '\n' + then notices ^ body + else notices ^ "\n" ^ body + + let reply (r : EcLlmCore.reply) = + let body = + match r.EcLlmCore.body with + | EcLlmCore.Text body -> body + | EcLlmCore.Goals -> EcLlmCore.current_goals st + in + make + ~text:(join r.EcLlmCore.notices body) + ~uuid:r.EcLlmCore.uuid + ~changed:r.EcLlmCore.changed + ~is_error:false ~extra:[] + + (* A prover error is data, not a protocol failure: it comes back as + a successful response the agent can read and act on. *) + let failure ~extra (f : EcLlmCore.failure) = + let body = + if f.EcLlmCore.goals = "" then f.EcLlmCore.message + else f.EcLlmCore.message ^ "\n" ^ f.EcLlmCore.goals + in + make + ~text:(join f.EcLlmCore.notices body) + ~uuid:f.EcLlmCore.uuid + ~changed:f.EcLlmCore.changed + ~is_error:true ~extra:(extra f) + + let outcome ?(extra = fun _ -> []) = function + | Ok r -> reply r + | Error f -> failure ~extra f + end in + + (* ------------------------------------------------------------------ *) + (* Tool dispatch. + + Argument checking happens here, before the engine is touched: the + core trusts what it is handed, [EcLlmCore.load] resetting the + session before it so much as opens the file. Schema violations + raise [Invalid_params] and become JSON-RPC errors; checks a tool + makes on its own behalf raise [Tool_error] and become [isError] + results. The checks the REPL makes too live in [EcLlmCore] and are + only reported here. *) + + (* Set by a phrase that ends the session ([exit.]): the response still + goes out, then the process stops. *) + let quitting = ref false in + + let answer ?(extra = fun _ -> []) = function + | EcLlmCore.Quit -> + quitting := true; + Result_of.make ~text:"session terminated" + ~uuid:(EcLlmCore.uuid st) ~changed:false ~is_error:false ~extra:[] + | EcLlmCore.Done outcome -> + Result_of.outcome ~extra outcome + in + + let call_tool (name : string) (params : J.t option) : J.t = + let args = Args.arguments params in + let outcome = Result_of.outcome in + + match name with + | "ec_load" -> + let file = Args.string_req name args "file" in + let line = Args.int_opt name args "line" in + let col = Args.int_opt name args "col" in + let nosmt = Args.bool_opt name args "nosmt" ~default:false in + let noprf = Args.bool_opt name args "noproof" ~default:false in + let trace = Args.bool_opt name args "trace" ~default:false in + if line = None && col <> None then + raise (Invalid_params "ec_load: `col' requires `line'"); + (match EcLlmCore.check_load_file file with + | Ok () -> () + | Error msg -> raise (Tool_error msg)); + let upto = Option.map (fun line -> (line, col)) line in + outcome (EcLlmCore.load st ~file ~upto ~nosmt ~noproof:noprf ~trace) + + | "ec_step" -> + answer (EcLlmCore.step st (Args.string_req name args "phrase")) + + | "ec_try" -> + answer + ~extra:(fun (f : EcLlmCore.failure) -> + [("reverted", `Bool f.EcLlmCore.reverted)]) + (EcLlmCore.try_step st (Args.string_req name args "phrase")) + + | "ec_goals" -> + outcome (EcLlmCore.goals st + ~all:(Args.bool_opt name args "all" ~default:false)) + + | "ec_tree" -> + outcome (EcLlmCore.tree st + ~all:(Args.bool_opt name args "full" ~default:false)) + + | "ec_focus" -> + outcome (EcLlmCore.focus st + (focus_target (Args.string_req name args "path"))) + + | "ec_undo" -> + outcome (EcLlmCore.undo st) + + | "ec_revert" -> + outcome (EcLlmCore.revert st (Args.string_req name args "target")) + + | "ec_checkpoint" -> + outcome (EcLlmCore.checkpoint st + ~name:(Args.string_req name args "name")) + + | "ec_commit" -> + outcome (EcLlmCore.commit st) + + | "ec_strict" -> + outcome (EcLlmCore.strict st ~on:(Args.bool_req name args "on")) + + | "ec_resume" -> + outcome (EcLlmCore.resume st) + + | "ec_search" -> + outcome (EcLlmCore.search st + ~pattern:(Args.string_req name args "pattern")) + + | _ -> + raise (Invalid_params (Printf.sprintf "unknown tool: %s" name)) + in + + (* ------------------------------------------------------------------ *) + (* Requests. *) + let request id (meth : string) (params : J.t option) = + try + match meth with + | "initialize" -> + Wire.result id (initialize_result params) + | "ping" -> + Wire.result id (`Assoc []) + | "tools/list" -> + (* The tool set is static and short: no pagination, and a + [cursor] argument is simply ignored. *) + Wire.result id (`Assoc [("tools", `List tools)]) + | "tools/call" -> + let name = + match List.assoc_opt "name" (Args.of_params params) with + | Some (`String s) -> s + | Some _ -> raise (Invalid_params "`name' must be a string") + | None -> raise (Invalid_params "missing tool `name'") + in + let result = + try call_tool name params with + | Tool_error msg -> + Result_of.make ~text:msg ~uuid:(EcLlmCore.uuid st) + ~changed:false ~is_error:true ~extra:[] + in + Wire.result id result; + if !quitting then exit 0 + | _ -> + Wire.error id e_method_not_found + (Printf.sprintf "method not found: %s" meth) + with + | Invalid_params msg -> Wire.error id e_invalid_params msg + in + + (* Notifications never get a reply, whatever they are. The ones the + spec has us tolerate ([initialized], [cancelled], + [roots/list_changed]) are no-ops here, and so is anything else: + cancellation cannot preempt a synchronous tool call. *) + let notification (_ : string) (_ : J.t option) = () in + + (* ------------------------------------------------------------------ *) + let dispatch (msg : J.t) = + match msg with + | `List _ -> + (* Batching was removed from the protocol in revision 2025-06-18 + and has not come back. *) + Wire.error `Null e_invalid_request + "JSON-RPC batches are not supported by this protocol revision" + | `Assoc fields -> + let params = List.assoc_opt "params" fields in + let id = + (* A message is a request exactly when it carries a usable id; + MCP forbids a null id, so we read one as "no id" and stay + silent rather than answer a malformed request. *) + match List.assoc_opt "id" fields with + | None | Some `Null -> None + | Some id -> Some id + in + begin match List.assoc_opt "method" fields, id with + | Some (`String meth), Some id -> request id meth params + | Some (`String meth), None -> notification meth params + | Some _, Some id -> + Wire.error id e_invalid_request "`method' must be a string" + | Some _, None -> () + | None, Some id -> + Wire.error id e_invalid_request "missing `method'" + | None, None -> () + end + | _ -> + Wire.error `Null e_invalid_request + "a JSON-RPC message must be an object" + in + + (* ------------------------------------------------------------------ *) + (* Main loop. A blank line is not a message; skipping it keeps a + client's trailing newline from drawing a parse error. *) + begin try while true do + let line = input_line stdin in + if String.trim line <> "" then + match J.from_string line with + | exception _ -> + Wire.error `Null e_parse_error "invalid JSON" + | msg -> dispatch msg + done with End_of_file -> () end; + + exit 0 diff --git a/src/ecMcp.mli b/src/ecMcp.mli new file mode 100644 index 000000000..fe64f8565 --- /dev/null +++ b/src/ecMcp.mli @@ -0,0 +1,71 @@ +(* -------------------------------------------------------------------- *) +(* Model Context Protocol server over stdio: a second front-end, next to + the [easycrypt llm] REPL, over the shared engine core in + [EcLlmCore]. Driven via the [easycrypt mcp] command. *) + +module J = Yojson.Safe + +(* Serve JSON-RPC 2.0 messages on stdin/stdout until end of input, then + exit the process. Never returns. [projini] resolves the + [easycrypt.project] context of a file path, as for the REPL. *) +val run : + relocdir:string option + -> boot:bool + -> projini:(string option -> EcOptions.ini_context option) + -> EcOptions.mcp_option + -> 'a + +(* -------------------------------------------------------------------- *) +(* The pieces the session multiplexer ([EcMcpMux]) shares with the + single-engine server, so that the two front doors answer alike. *) + +val protocol_latest : string +val protocol_supported : string list +val server_name : string +val server_version : string + +val e_parse_error : int +val e_invalid_request : int +val e_method_not_found : int +val e_invalid_params : int + +(* A [tools/call] whose arguments violate the declared schema. *) +exception Invalid_params of string + +val print_usage : unit -> unit + +(* One JSON-RPC message per line. [writer oc] sends on [oc]; [Over] + builds the reply helpers over any [send]. *) +module Wire : sig + val repair : J.t -> J.t + val writer : out_channel -> J.t -> unit + val result_msg : J.t -> J.t -> J.t + val error_msg : ?data:J.t -> J.t -> int -> string -> J.t + module Over (C : sig val send : J.t -> unit end) : sig + val send : J.t -> unit + val result : J.t -> J.t -> unit + val error : ?data:J.t -> J.t -> int -> string -> unit + end +end + +(* A private descriptor for the protocol; the process's stdout is + pointed at stderr. Call once, before anything can print. *) +val wire_stdout : unit -> out_channel + +module Schema : sig + val str : ?description:string -> unit -> J.t + val obj : ?required:string list -> (string * J.t) list -> J.t +end + +(* The tool table, and the same table with a required [session] + argument on every tool. *) +val tools : J.t list +val tools_with_session : J.t list + +module Args : sig + val of_params : J.t option -> (string * J.t) list + val arguments : J.t option -> (string * J.t) list +end + +(* The [initialize] result for the given request parameters. *) +val initialize_result : J.t option -> J.t diff --git a/src/ecMcpMux.ml b/src/ecMcpMux.ml new file mode 100644 index 000000000..438a2234e --- /dev/null +++ b/src/ecMcpMux.ml @@ -0,0 +1,496 @@ +(* -------------------------------------------------------------------- *) +(* The session multiplexer behind [easycrypt mcp -sessions]. See + [ecMcpMux.mli]. + + One process, no engine: the multiplexer speaks MCP to the client and + forwards every tool call to a child [easycrypt mcp] -- the + single-engine server of [EcMcp], unchanged -- chosen by the + [session] argument the call names. The first call naming a session + starts its child; the children are independent engines, so agents + work in parallel, each with its own loaded file, uuids and + checkpoints. + + Why processes and not several [EcLlmCore] states in one process: + the engine is single-threaded (one agent loading a large file would + block every other session), the loader cache, prover configuration + and Why3 processes are global, and a loaded engine holds memory that + only a process exit gives back. + + Requests are served concurrently, one thread each. The threads do + I/O and JSON and nothing else, so the runtime lock costs nothing: + the invariants are one mutex per child (a child is synchronous, so + calls to the same session serialise on it), one mutex for the + client's stdout, and one for the session table. *) + +module J = Yojson.Safe + +open EcMcp + +(* -------------------------------------------------------------------- *) +(* Session names double as log-file names, and travel to the child as + nothing (the name is stripped before forwarding). A restricted + alphabet keeps a name from being a path. *) +let valid_name (name : string) = + name <> "" + && String.length name <= 64 + && String.for_all + (fun c -> + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') + || (c >= '0' && c <= '9') || c = '_' || c = '-' || c = '.') + name + +(* Raised while talking to a child that is gone, or going: the session + is dropped and the caller told to start over. *) +exception Child_gone of string + +(* -------------------------------------------------------------------- *) +(* One child engine. Every field but [last_used] and [dead] is set at + spawn time; those two, and the channels, are touched under [lock] + only -- except by [kill], which is the point of no return anyway. *) +type session = { + name : string; + pid : int; + to_child : out_channel; + from_child : in_channel; + log : Unix.file_descr; + lock : Mutex.t; + mutable last_used : float; + mutable next_id : int; + mutable dead : bool; (* exit observed and reaped *) +} + +(* The child's command line: our own executable, our own arguments + minus the multiplexer's, so that loader and prover options reach the + engine as they reached us. [Arg] accepts both [-idle 5] and + [-idle=5]; strip both spellings. *) +let child_argv () : string array = + let rec strip = function + | [] -> [] + | "-sessions" :: rest -> strip rest + | ("-idle" | "-logdir") :: _ :: rest -> strip rest + | arg :: rest + when String.starts_with ~prefix:"-idle=" arg + || String.starts_with ~prefix:"-logdir=" arg -> strip rest + | arg :: rest -> arg :: strip rest + in + match Array.to_list Sys.argv with + | [] -> [| Sys.executable_name; "mcp" |] + | _ :: args -> Array.of_list (Sys.executable_name :: strip args) + +let now () = Unix.gettimeofday () + +(* Talking to a child. The caller holds [s.lock]. A child is + synchronous, so the reply to a request is the next line carrying its + id; anything else on the way (there should be nothing) is skipped. *) +let write_child (s : session) (msg : J.t) = + try Wire.writer s.to_child msg with + | Sys_error e -> raise (Child_gone e) + | Unix.Unix_error (e, _, _) -> raise (Child_gone (Unix.error_message e)) + +let notify_child (s : session) (meth : string) = + write_child s (`Assoc [("jsonrpc", `String "2.0"); ("method", `String meth)]) + +let request_child (s : session) (meth : string) (params : J.t) : J.t = + let id = s.next_id in + s.next_id <- id + 1; + write_child s (`Assoc [ + ("jsonrpc", `String "2.0"); + ("id", `Int id); + ("method", `String meth); + ("params", params); + ]); + let rec read () = + let line = + try input_line s.from_child with + | End_of_file -> raise (Child_gone "the engine exited") + | Sys_error e -> raise (Child_gone e) + in + if String.trim line = "" then read () + else + match J.from_string line with + | exception _ -> read () + | `Assoc fields as reply -> + (match List.assoc_opt "id" fields with + | Some (`Int i) when i = id -> reply + | _ -> read ()) + | _ -> read () + in + read () + +(* Reap without blocking; once reaped, a child stays dead. *) +let alive (s : session) = + if s.dead then false + else + match Unix.waitpid [Unix.WNOHANG] s.pid with + | (0, _) -> true + | _ -> s.dead <- true; false + | exception Unix.Unix_error _ -> s.dead <- true; false + +let kill (s : session) = + let quietly f = try f () with _ -> () in + quietly (fun () -> Unix.kill s.pid Sys.sigkill); + quietly (fun () -> close_out s.to_child); + quietly (fun () -> close_in s.from_child); + quietly (fun () -> Unix.close s.log); + if not s.dead then begin + (* SIGKILL cannot be caught, so this returns at once. *) + quietly (fun () -> ignore (Unix.waitpid [] s.pid)); + s.dead <- true + end + +(* Start the process and the pipes; the handshake is the caller's, so + that it can run under the session's lock rather than the table's. *) +let spawn ~(logdir : string) (name : string) : session = + let (child_in, our_out) = Unix.pipe ~cloexec:true () in + let (our_in, child_out) = Unix.pipe ~cloexec:true () in + let log = + Unix.openfile + (Filename.concat logdir (Printf.sprintf "ec-mcp-%s.log" name)) + [Unix.O_WRONLY; Unix.O_CREAT; Unix.O_APPEND; Unix.O_CLOEXEC] 0o644 + in + let pid = + try + Unix.create_process Sys.executable_name (child_argv ()) + child_in child_out log + with e -> + List.iter Unix.close [child_in; our_out; our_in; child_out; log]; + raise e + in + Unix.close child_in; + Unix.close child_out; + { name; pid; log; + to_child = Unix.out_channel_of_descr our_out; + from_child = Unix.in_channel_of_descr our_in; + lock = Mutex.create (); + last_used = now (); + next_id = 1; + dead = false; } + +let handshake (s : session) = + let params = + `Assoc [ + ("protocolVersion", `String protocol_latest); + ("capabilities", `Assoc []); + ("clientInfo", `Assoc [ + ("name", `String (server_name ^ "-sessions")); + ("version", `String server_version); + ]); + ] + in + ignore (request_child s "initialize" params); + notify_child s "notifications/initialized" + +(* -------------------------------------------------------------------- *) +(* The multiplexer's own tools, appended to the child's table. *) +let mux_tools : J.t list = [ + `Assoc [ + ("name", `String "ec_sessions"); + ("description", + `String "List the live EasyCrypt engine sessions of this server: \ + one line per session with its name, pid and idle time. \ + Sessions are created by the first tool call that names \ + them and killed by ec_close or after the idle timeout."); + ("inputSchema", Schema.obj []); + ("annotations", `Assoc [("readOnlyHint", `Bool true)]); + ]; + `Assoc [ + ("name", `String "ec_close"); + ("description", + `String "Kill the engine of the named session and forget it. Its \ + memory is released; the next call naming that session \ + starts a fresh engine, which needs an ec_load. Close your \ + own session when you are done with it, never another \ + agent's."); + ("inputSchema", + Schema.obj ~required:["session"] [ + ("session", Schema.str ~description:"the session to close" ()); + ]); + ]; +] + +let tools : J.t list = tools_with_session @ mux_tools + +(* -------------------------------------------------------------------- *) +let run (mcpopts : EcOptions.mcp_option) = + if mcpopts.EcOptions.mcpo_help then begin + print_usage (); + exit 0 + end; + + let idle = + 60. *. float_of_int (Option.value mcpopts.EcOptions.mcpo_idle ~default:180) + in + let logdir = + match mcpopts.EcOptions.mcpo_logdir with + | Some dir -> dir + | None -> + match Sys.getenv_opt "TMPDIR" with + | Some dir when dir <> "" -> dir + | _ -> "/tmp" + in + + (* A child that died between two of our writes must surface as an + [EPIPE] we can report on that session, not as a signal that takes + the whole server down. *) + Sys.set_signal Sys.sigpipe Sys.Signal_ignore; + + let wire = wire_stdout () in + let out_lock = Mutex.create () in + let module Wire = Wire.Over (struct + let send msg = Mutex.protect out_lock (fun () -> Wire.writer wire msg) + end) in + + (* ------------------------------------------------------------------ *) + (* The session table. *) + let sessions : (string, session) Hashtbl.t = Hashtbl.create 8 in + let table_lock = Mutex.create () in + let with_table f = Mutex.protect table_lock f in + + (* Drop [s] if it is still the session registered under its name: a + name can have been closed and re-created while a call on the old + child was in flight, and that call must not take the new one down. *) + let drop (s : session) = + with_table (fun () -> + match Hashtbl.find_opt sessions s.name with + | Some s' when s' == s -> Hashtbl.remove sessions s.name + | _ -> ()); + kill s + in + + let close (name : string) = + match with_table (fun () -> + let s = Hashtbl.find_opt sessions name in + Option.iter (fun _ -> Hashtbl.remove sessions name) s; + s) + with + | None -> false + | Some s -> kill s; true + in + + (* Runs on the way out, possibly from a signal handler in a thread + that holds the table lock: no lock here, the process is ending and + a torn snapshot costs at most a child the pipe closes anyway. *) + let kill_all () = + let all = try Hashtbl.fold (fun _ s acc -> s :: acc) sessions [] with _ -> [] in + List.iter kill all + in + + (* Find the session, or start it. The table lock covers the lookup + and the process start only; the handshake, which waits for the + child to be ready, runs under the session's own lock so that a + second call on the same new name queues behind it rather than + stalling every other session. *) + let get (name : string) : session = + let (s, fresh) = + with_table (fun () -> + match Hashtbl.find_opt sessions name with + | Some s when alive s -> (s, false) + | stale -> + Option.iter kill stale; + let s = spawn ~logdir name in + Hashtbl.replace sessions name s; + Mutex.lock s.lock; + (s, true)) + in + if fresh then begin + (* We hold [s.lock] from inside the table section above. *) + match handshake s with + | () -> Mutex.unlock s.lock + | exception e -> Mutex.unlock s.lock; drop s; raise e + end; + s + in + + (* ------------------------------------------------------------------ *) + (* Children must not outlive us: on any exit we can see coming, kill + them; on the ones we cannot (SIGKILL), they read EOF on their stdin + and stop once their current command completes. *) + at_exit kill_all; + List.iter + (fun signal -> + Sys.set_signal signal (Sys.Signal_handle (fun _ -> exit 0))) + [Sys.sigterm; Sys.sigint; Sys.sighup]; + + (* The idle reaper: a session unused for [idle] and not mid-call. *) + if idle > 0. then + ignore (Thread.create (fun () -> + while true do + Thread.delay 60.; + let stale = + with_table (fun () -> + Hashtbl.fold (fun name s acc -> + if now () -. s.last_used > idle && Mutex.try_lock s.lock + then (Mutex.unlock s.lock; name :: acc) + else acc) + sessions []) + in + List.iter (fun name -> ignore (close name)) stale + done) ()); + + (* ------------------------------------------------------------------ *) + (* Tool results of our own. *) + let text_result ?(is_error = false) text : J.t = + `Assoc ([ + ("content", `List [`Assoc [("type", `String "text"); + ("text", `String text)]]); + ] @ (if is_error then [("isError", `Bool true)] else [])) + in + + let list_sessions () = + let rows = + with_table (fun () -> + Hashtbl.fold (fun name s acc -> (name, s) :: acc) sessions []) + |> List.sort (fun (a, _) (b, _) -> compare a b) + |> List.map (fun (name, s) -> + Printf.sprintf "%s pid %d idle %ds%s" name s.pid + (int_of_float (now () -. s.last_used)) + (if alive s then "" else " (dead)")) + in + text_result (if rows = [] then "no live session" else String.concat "\n" rows) + in + + (* A forwarded call. The child's [result] or [error] comes back to + the client under the client's id; a child that dies under us is + dropped, and the caller told so in a tool-level error. *) + let forward id (tool : string) (args : (string * J.t) list) = + let name = + match List.assoc_opt "session" args with + | Some (`String s) when String.trim s <> "" -> String.trim s + | _ -> + Wire.result id (text_result ~is_error:true + "missing `session': name your own engine session (e.g. your \ + agent tag) in every call"); + raise Exit + in + if not (valid_name name) then begin + Wire.result id (text_result ~is_error:true + (Printf.sprintf + "invalid session name `%s': use letters, digits, `_', `-' and \ + `.' only (at most 64 characters)" name)); + raise Exit + end; + let args = List.filter (fun (k, _) -> k <> "session") args in + let params = + `Assoc [("name", `String tool); ("arguments", `Assoc args)] in + match + let s = get name in + Mutex.protect s.lock (fun () -> + s.last_used <- now (); + match request_child s "tools/call" params with + | reply -> s.last_used <- now (); reply + | exception e -> s.last_used <- now (); drop s; raise e) + with + | `Assoc fields -> + (match List.assoc_opt "result" fields, List.assoc_opt "error" fields with + | Some result, _ -> Wire.result id result + | None, Some (`Assoc err) -> + let code = + match List.assoc_opt "code" err with + | Some (`Int c) -> c | _ -> -32603 + and message = + match List.assoc_opt "message" err with + | Some (`String m) -> m | _ -> "error" + in + Wire.error ?data:(List.assoc_opt "data" err) id code message + | _ -> + Wire.result id (text_result ~is_error:true + (Printf.sprintf "session `%s': malformed reply from the engine" + name))) + | _ -> assert false + | exception Child_gone reason -> + Wire.result id (text_result ~is_error:true + (Printf.sprintf + "session `%s': %s (the session was dropped; the next call \ + starts a fresh engine, ec_load again)" name reason)) + | exception Unix.Unix_error (e, fn, arg) -> + Wire.result id (text_result ~is_error:true + (Printf.sprintf "session `%s': cannot start the engine: %s (%s %s)" + name (Unix.error_message e) fn arg)) + in + + let call_tool id (params : J.t option) = + let name = + match List.assoc_opt "name" (Args.of_params params) with + | Some (`String s) -> s + | Some _ -> raise (Invalid_params "`name' must be a string") + | None -> raise (Invalid_params "missing tool `name'") + in + let args = Args.arguments params in + match name with + | "ec_sessions" -> + Wire.result id (list_sessions ()) + | "ec_close" -> + (match List.assoc_opt "session" args with + | Some (`String n) when close (String.trim n) -> + Wire.result id (text_result (Printf.sprintf "closed %s" (String.trim n))) + | Some (`String n) -> + Wire.result id (text_result (Printf.sprintf "no session `%s'" (String.trim n))) + | Some _ -> raise (Invalid_params "ec_close: `session' must be a string") + | None -> + raise (Invalid_params "ec_close: missing required argument `session'")) + | _ -> + (try forward id name args with Exit -> ()) + in + + (* ------------------------------------------------------------------ *) + let request id (meth : string) (params : J.t option) = + try + match meth with + | "initialize" -> Wire.result id (initialize_result params) + | "ping" -> Wire.result id (`Assoc []) + | "tools/list" -> Wire.result id (`Assoc [("tools", `List tools)]) + | "tools/call" -> call_tool id params + | _ -> + Wire.error id e_method_not_found + (Printf.sprintf "method not found: %s" meth) + with + | Invalid_params msg -> Wire.error id e_invalid_params msg + | e -> + (* A request thread must always answer: an exception that escaped + everything above becomes an internal error, not a client that + waits forever. *) + Wire.error id (-32603) + (Printf.sprintf "internal error: %s" (Printexc.to_string e)) + in + + let dispatch (msg : J.t) = + match msg with + | `List _ -> + Wire.error `Null e_invalid_request + "JSON-RPC batches are not supported by this protocol revision" + | `Assoc fields -> + let params = List.assoc_opt "params" fields in + let id = + match List.assoc_opt "id" fields with + | None | Some `Null -> None + | Some id -> Some id + in + begin match List.assoc_opt "method" fields, id with + | Some (`String meth), Some id -> + ignore (Thread.create (fun () -> request id meth params) ()) + | Some (`String _), None -> () (* notifications *) + | Some _, Some id -> + Wire.error id e_invalid_request "`method' must be a string" + | Some _, None -> () + | None, Some id -> + Wire.error id e_invalid_request "missing `method'" + | None, None -> () + end + | _ -> + Wire.error `Null e_invalid_request + "a JSON-RPC message must be an object" + in + + (* ------------------------------------------------------------------ *) + begin try while true do + let line = input_line stdin in + if String.trim line <> "" then + match J.from_string line with + | exception _ -> + Wire.error `Null e_parse_error "invalid JSON" + | msg -> dispatch msg + done with End_of_file -> () end; + + (* The client is gone: take the engines with us. [exit] runs + [kill_all] through [at_exit]. *) + exit 0 diff --git a/src/ecMcpMux.mli b/src/ecMcpMux.mli new file mode 100644 index 000000000..d5bcab436 --- /dev/null +++ b/src/ecMcpMux.mli @@ -0,0 +1,10 @@ +(* -------------------------------------------------------------------- *) +(* The session multiplexer behind [easycrypt mcp -sessions]: an MCP + server on stdio that runs one child [easycrypt mcp] engine per + session name and forwards each tool call to the session it names. + Sessions are independent engines, so several agents can drive one + server without sharing a proof state. *) + +(* Serve until end of input, then kill every child and exit. Never + returns. *) +val run : EcOptions.mcp_option -> 'a diff --git a/src/ecOptions.ml b/src/ecOptions.ml index a78822aaf..58354644a 100644 --- a/src/ecOptions.ml +++ b/src/ecOptions.ml @@ -11,6 +11,7 @@ type command = [ | `Why3Config | `DocGen of doc_option | `Llm of llm_option + | `Mcp of mcp_option ] and options = { @@ -49,10 +50,17 @@ and doc_option = { } and llm_option = { - llmo_input : string; llmo_provers : prv_options; - llmo_lastgoals : bool; - llmo_upto : (int * int option) option; + llmo_help : bool; + llmo_eval : string option; +} + +and mcp_option = { + mcpo_provers : prv_options; + mcpo_help : bool; + mcpo_sessions : bool; + mcpo_idle : int option; + mcpo_logdir : string option; } and prv_options = { @@ -69,8 +77,9 @@ and prv_options = { } and ldr_options = { - ldro_idirs : (string option * string * bool) list; - ldro_boot : bool; + ldro_idirs : (string option * string * bool) list; + ldro_boot : bool; + ldro_stdlib : string list; } and glb_options = { @@ -381,11 +390,19 @@ let specs = { `Spec ("trace" , `Flag , "Save all goals & messages in .eco"); `Spec ("compact", `Int , "")]); - ("llm", "LLM-friendly batch compilation", [ + ("llm", "LLM-friendly interactive mode", [ + `Group "loader"; + `Group "provers"; + `Spec ("help", `Flag , "Print the LLM agent guide and exit"); + `Spec ("eval", `String, "Run the given commands (newline-separated) and exit, in lieu of reading stdin")]); + + ("mcp", "Model Context Protocol server (stdio)", [ `Group "loader"; `Group "provers"; - `Spec ("lastgoals" , `Flag , "Print last unproved goals on failure"); - `Spec ("upto" , `String, "Compile up to LINE or LINE:COL and print goals")]); + `Spec ("help", `Flag , "Print the MCP server usage and exit"); + `Spec ("sessions", `Flag, "Run one engine per named session, in child processes"); + `Spec ("idle", `Int , "With -sessions: kill a session unused for minutes (default 180)"); + `Spec ("logdir", `String, "With -sessions: write the sessions' logs to (default $TMPDIR)")]); ("cli", "Run EasyCrypt top-level", [ `Group "loader"; @@ -424,9 +441,10 @@ let specs = { ]); ("loader", "Options related to loader", [ - `Spec ("I" , `String, "Add to the list of include directories"); - `Spec ("R" , `String, "Recursively add to the list of include directories"); - `Spec ("boot", `Flag , "Don't load prelude")]) + `Spec ("I" , `String, "Add to the list of include directories"); + `Spec ("R" , `String, "Recursively add to the list of include directories"); + `Spec ("stdlib", `String, "Use as a standard-library root (System namespace, prelude + recursive), replacing the built-in one; repeatable"); + `Spec ("boot" , `Flag , "Don't load prelude")]) ] } @@ -486,8 +504,9 @@ let dirs_of_env = (* -------------------------------------------------------------------- *) let ldr_options_of_values ~env ?(ini = []) values = + let stdlib = get_strings "stdlib" values in if get_flag "boot" values then - { ldro_idirs = []; ldro_boot = true; } + { ldro_idirs = []; ldro_boot = true; ldro_stdlib = stdlib; } else let add_rec (fl : bool) ((nm, x) : string option * string) = (nm, x, fl) in @@ -501,8 +520,9 @@ let ldr_options_of_values ~env ?(ini = []) values = let rdirs = List.map (add_rec true) rdirs in let idirs_R = List.map (add_rec true) (List.map parse_idir (get_strings "R" values)) in - { ldro_idirs = idirs @ idirs_I @ rdirs @ idirs_R; - ldro_boot = false; } + { ldro_idirs = idirs @ idirs_I @ rdirs @ idirs_R; + ldro_boot = false; + ldro_stdlib = stdlib; } let glb_options_of_values ~env ini values = let why3 = @@ -548,6 +568,47 @@ let prv_options_of_values ini values = prvo_why3server = get_string "why3server" values; } +(* -------------------------------------------------------------------- *) +(* Overlay project INI settings (an [easycrypt.project] discovered when + a file is loaded at run time, e.g. by the LLM REPL's [LOAD]) on top + of already-parsed prover options. Mirrors the precedence used by + [prv_options_of_values] when the project file is known at + option-parsing time: project provers/pragmas extend the parsed + lists, project scalars take over the parsed values. *) +let prv_options_with_ini (ini : ini_context list) (prv : prv_options) = + let provers = + match Ini.get_all_provers ini with + | [] -> prv.prvo_provers + | ps -> + let old = odfl [] prv.prvo_provers in + Some (ps @ List.filter (fun p -> not (List.mem p ps)) old) + in + { prv with + prvo_provers = provers; + prvo_timeout = begin + match Ini.get_all_timeout ini with + | None -> prv.prvo_timeout + | Some _ as i -> i + end; + prvo_quorum = begin + match Ini.get_all_quorum ini with + | None -> prv.prvo_quorum + | Some _ as i -> i + end; + prvo_ppwidth = begin + match Ini.get_all_ppwidth ini with + | None -> prv.prvo_ppwidth + | Some _ as i -> i + end; + prvo_pragmas = Ini.get_all_pragmas ini @ prv.prvo_pragmas; } + +(* The load path contributed by INI contexts, in the shape and order of + [ldro_idirs]: plain include dirs first, then recursive ones. *) +let ini_loadpath (ini : ini_context list) = + List.map (fun (nm, dir) -> (nm, dir, false)) (Ini.get_all_idirs ini) + @ List.map (fun (nm, dir) -> (nm, dir, true)) (Ini.get_all_rdirs ini) + +(* -------------------------------------------------------------------- *) let cli_options_of_values ini values = { clio_emacs = get_flag "emacs" values; clio_provers = prv_options_of_values ini values; } @@ -574,26 +635,17 @@ let doc_options_of_values values input = { doco_input = input; doco_outdirp = get_string "outdir" values; } -let parse_upto values = - get_string "upto" values |> Option.map (fun s -> - let invalid () = - raise (Arg.Bad (Printf.sprintf - "invalid -upto format: expected LINE or LINE:COL, got %S" s)) in - match String.split_on_char ':' s with - | [line] -> - let line = try int_of_string line with Failure _ -> invalid () in - (line, None) - | [line; col] -> - let line = try int_of_string line with Failure _ -> invalid () in - let col = try int_of_string col with Failure _ -> invalid () in - (line, Some col) - | _ -> invalid ()) - -let llm_options_of_values ini values input = - { llmo_input = input; - llmo_provers = prv_options_of_values ini values; - llmo_lastgoals = get_flag "lastgoals" values; - llmo_upto = parse_upto values; } +let llm_options_of_values ini values = + { llmo_provers = prv_options_of_values ini values; + llmo_help = get_flag "help" values; + llmo_eval = get_string "eval" values; } + +let mcp_options_of_values ini values = + { mcpo_provers = prv_options_of_values ini values; + mcpo_help = get_flag "help" values; + mcpo_sessions = get_flag "sessions" values; + mcpo_idle = get_int "idle" values; + mcpo_logdir = get_string "logdir" values; } (* -------------------------------------------------------------------- *) let parse getini argv = @@ -666,16 +718,23 @@ let parse getini argv = raise (Arg.Bad "this command takes a single input file as argument") end - | "llm" -> begin - match anons with - | [input] -> - let ini = getini (Some input) in - let cmd = `Llm (llm_options_of_values ini values input) in - (cmd, ini, true) + | "llm" -> + if not (List.is_empty anons) then + raise (Arg.Bad "this command does not take arguments"); - | _ -> - raise (Arg.Bad "this command takes a single argument") - end + let ini = getini None in + let cmd = `Llm (llm_options_of_values ini values) in + + (cmd, ini, true) + + | "mcp" -> + if not (List.is_empty anons) then + raise (Arg.Bad "this command does not take arguments"); + + let ini = getini None in + let cmd = `Mcp (mcp_options_of_values ini values) in + + (cmd, ini, true) | _ -> assert false diff --git a/src/ecOptions.mli b/src/ecOptions.mli index 0fb1fc3c2..c4670b333 100644 --- a/src/ecOptions.mli +++ b/src/ecOptions.mli @@ -7,6 +7,7 @@ type command = [ | `Why3Config | `DocGen of doc_option | `Llm of llm_option + | `Mcp of mcp_option ] and options = { @@ -45,10 +46,17 @@ and doc_option = { } and llm_option = { - llmo_input : string; llmo_provers : prv_options; - llmo_lastgoals : bool; - llmo_upto : (int * int option) option; + llmo_help : bool; + llmo_eval : string option; +} + +and mcp_option = { + mcpo_provers : prv_options; + mcpo_help : bool; + mcpo_sessions : bool; + mcpo_idle : int option; + mcpo_logdir : string option; } and prv_options = { @@ -65,8 +73,12 @@ and prv_options = { } and ldr_options = { - ldro_idirs : (string option * string * bool) list; - ldro_boot : bool; + ldro_idirs : (string option * string * bool) list; + ldro_boot : bool; + ldro_stdlib : string list; + (* When non-empty, these directories replace the built-in + [Sites.theories] for prelude and recursive-System namespace + loading. Empty means "use the built-in stdlib". *) } and glb_options = { @@ -99,6 +111,16 @@ exception InvalidIniFile of (int * string) val read_ini_file : string -> ini_options +(* -------------------------------------------------------------------- *) +(* Overlay project INI settings discovered at run time (e.g. by the LLM + REPL's [LOAD]) on top of already-parsed prover options, mirroring + the precedence of option parsing with a known project file. *) +val prv_options_with_ini : ini_context list -> prv_options -> prv_options + +(* The load path contributed by INI contexts, in [ldro_idirs] shape: + (namespace, dir, recursive). *) +val ini_loadpath : ini_context list -> (string option * string * bool) list + val parse_cmdline : ?ini:(string option -> ini_context list) -> string array diff --git a/src/ecScope.ml b/src/ecScope.ml index cdcf8e698..b195821d5 100644 --- a/src/ecScope.ml +++ b/src/ecScope.ml @@ -216,6 +216,18 @@ module Check_mode = struct let set_fullcheck options = GenOptions.set options oid (Check `Forced) + + (* Unconditional read/write of the mode, for a caller that wants to + turn checking off and later put back exactly what was there. + [set_checkproof] cannot do it: it is a toggle between [`On] and + [`Off] and silently ignores [`Forced]. *) + let get options = + match GenOptions.get options oid with + | Check mode -> mode + | _ -> `On + + let set options (mode : mode) = + GenOptions.set options oid (Check mode) end (* -------------------------------------------------------------------- *) @@ -489,6 +501,10 @@ let goal (scope : scope) = let xgoal (scope : scope) = scope.sc_pr_uc +(* -------------------------------------------------------------------- *) +let set_xgoal (scope : scope) (puc : proof_uc) = + { scope with sc_pr_uc = Some puc } + (* -------------------------------------------------------------------- *) let dump_why3 (scope : scope) (filename : string) = try EcSmt.dump_why3 (env scope) filename @@ -782,6 +798,15 @@ module Prover = struct (* -------------------------------------------------------------------- *) let check_proof scope b = { scope with sc_options = Check_mode.set_checkproof scope.sc_options b } + + (* -------------------------------------------------------------------- *) + type check_mode = Check_mode.mode + + let get_check_mode scope = + Check_mode.get scope.sc_options + + let set_check_mode scope (mode : check_mode) = + { scope with sc_options = Check_mode.set scope.sc_options mode } end (* -------------------------------------------------------------------- *) @@ -2036,6 +2061,31 @@ module Theory = struct Msym.add ri.rqd_name (oget cth, rqs) new_.sc_loaded; } in bump_prelude (require_loaded ri scope) + (* The elaborated theories this scope holds, and the seeding of a + fresh scope with theories elaborated in an earlier one. + + [sc_loaded] is what spares a session the cost of reading a theory + twice: [require] consults it before it runs a loader. It is per + scope, so it dies with the scope -- and a front-end that rebuilds + the scope to reload a file (the LLM REPL's LOAD does) pays every + [require] again, which on a development of any size is the whole + cost of the reload. These two let a caller carry the table across + that rebuild. Whether the theories are still the ones the files on + disk describe is the caller's to answer: nothing here re-reads a + file, and [seed] believes what it is given. *) + let loaded (scope : scope) (name : symbol) : (thloaded * required) option = + Msym.find_opt name scope.sc_loaded + + let seed_loaded (scope : scope) + (entries : (symbol * (thloaded * required)) list) : scope + = + assert (scope.sc_pr_uc = None); + let sc_loaded = + List.fold_left + (fun loaded (name, entry) -> Msym.add name entry loaded) + scope.sc_loaded entries + in { scope with sc_loaded } + let require (scope : scope) ((name, mode) : required_info * thmode) loader = assert (scope.sc_pr_uc = None); @@ -2045,7 +2095,12 @@ module Theory = struct else scope end else match Msym.find_opt name.rqd_name scope.sc_loaded with - | Some _ -> require_loaded name scope + (* [bump_prelude], as on the loading path below: while the scope + is still the prelude's, every require it takes has to move the + snapshot [for_loading] later rewinds to. The loading path has + always done it, this one never had to -- nothing reached it + during the prelude -- and a seeded [sc_loaded] does. *) + | Some _ -> bump_prelude (require_loaded name scope) | None -> try let imported = require_start scope name.rqd_name mode in diff --git a/src/ecScope.mli b/src/ecScope.mli index d73ed66d7..673c2f01c 100644 --- a/src/ecScope.mli +++ b/src/ecScope.mli @@ -27,6 +27,11 @@ type required_info = { type required = required_info list +(* An elaborated theory, as [Theory.loaded] hands it back and + [Theory.seed_loaded] takes it: opaque here, and only ever moved from + one scope to another. *) +type thloaded + type scope type proof_uc = { @@ -87,6 +92,7 @@ val env : scope -> EcEnv.env val attop : scope -> bool val goal : scope -> proof_auc option val xgoal : scope -> proof_uc option +val set_xgoal : scope -> proof_uc -> scope (* Creates a scope that is identical to the supplied one except * that the environment and required theories are reset to the ones @@ -197,6 +203,22 @@ module Theory : sig * theory. *) val require : scope -> (required_info * thmode) -> (scope -> scope) -> scope + (* [loaded scope name] is the elaborated theory [name] this scope has + already read, with the theories reading it required, or [None]. + [seed_loaded scope entries] puts such entries into a scope, so that + a [require] naming one of them takes the loaded path and never runs + its loader. + + They exist for a front-end that rebuilds the scope in order to + reload a file: the table [require] consults is part of the scope, + so without them every reload re-reads every required theory, which + is the bulk of what a reload costs. Nothing here looks at the file + system: a caller that seeds a theory the sources no longer describe + gets a session built on the stale one, so validating the entries + against disk before seeding them is the caller's job. *) + val loaded : scope -> symbol -> (thloaded * required) option + val seed_loaded : scope -> (symbol * (thloaded * required)) list -> scope + (* start/finish adding a new top-level required theory, not using loader * * [require_start] enters the theory, with the given name and theory mode, @@ -268,6 +290,19 @@ module Prover : sig val full_check : scope -> scope val check_proof : scope -> bool -> scope + (* Whether lemma proofs are checked in this scope. [`Off] makes every + lemma an axiom: [Ax.add] starts it in [PSNoCheck], its proof script + is not even typed, and [qed] binds the statement as it stands. This + is the mode a [require]d file is read in ([`Forced] is the [-check- + all] override that survives that switch). Unlike [check_proof], + which is a toggle that ignores [`Forced], these two read and write + the mode as it is, so a caller can turn checking off for a while + and then restore exactly what was in force. *) + type check_mode = [`Off | `On | `Forced] + + val get_check_mode : scope -> check_mode + val set_check_mode : scope -> check_mode -> scope + val pprover_infos_to_prover_infos : EcEnv.env -> EcProvers.prover_infos diff --git a/src/ecTerminal.ml b/src/ecTerminal.ml index c5f85bc81..ecda3f97e 100644 --- a/src/ecTerminal.ml +++ b/src/ecTerminal.ml @@ -148,7 +148,6 @@ type progress = [ `Human | `Script | `Silent ] class from_channel ?(gcstats : bool = true) ?(progress : progress option) - ?(lastgoals : bool = false) ~(name : string) (stream : in_channel) : terminal @@ -291,8 +290,6 @@ class from_channel let msg = String.strip (EcPException.tostring e) in self#_clean_progress_line (); - if lastgoals then - EcCommands.pp_current_goal_or_noproof ~all:true Format.std_formatter; self#_notice ?subloc ~immediate:true `Critical msg; self#_update_progress; self#_clean_progress_line ~erase:false (); @@ -317,5 +314,5 @@ class from_channel Format.pp_set_margin Format.err_formatter i end -let from_channel ?gcstats ?progress ?lastgoals ~name stream = - new from_channel ?gcstats ?progress ?lastgoals ~name stream +let from_channel ?gcstats ?progress ~name stream = + new from_channel ?gcstats ?progress ~name stream diff --git a/src/ecTerminal.mli b/src/ecTerminal.mli index faacff0e7..0a96a56d2 100644 --- a/src/ecTerminal.mli +++ b/src/ecTerminal.mli @@ -22,7 +22,6 @@ type progress = [ `Human | `Script | `Silent ] val from_channel : ?gcstats:bool -> ?progress:progress - -> ?lastgoals:bool -> name:string -> in_channel -> terminal diff --git a/tests/llm/README.md b/tests/llm/README.md new file mode 100644 index 000000000..3a0ac3bb8 --- /dev/null +++ b/tests/llm/README.md @@ -0,0 +1,162 @@ +# `easycrypt llm` golden-output tests + +Byte-identity regression harness for the LLM REPL (`src/ecLlm.ml`). +Each scenario is a small script of REPL commands fed to +`ec.exe llm -eval`; its raw stdout and its process exit status are +compared against recorded goldens. + +## Layout + +| Path | Contents | +|------|----------| +| `fixtures/*` | tiny EasyCrypt files the scripts `LOAD` (plus one non-`.ec` file, for the unknown-extension error, and one deliberately Latin-1 file used by `../mcp`) | +| `fixtures/sub/*` | a second directory, so a scenario can check that one `LOAD`'s include path does not survive into the next, and that a theory in it is elaborated once however often the file is reloaded | +| `scripts/*.script` | the newline-separated commands passed to `-eval` | +| `expected/*.out` | recorded stdout, one file per script | +| `../../scripts/testing/llm-golden` | the runner | +| `../../scripts/testing/llm-warm-reload` | a second runner, for what a `-eval` script cannot reach | + +## Running + +From the repository root: + +``` +make test-llm # build + run every scenario +scripts/testing/llm-golden # run every scenario +scripts/testing/llm-golden tree-nested commit-nested +scripts/testing/llm-golden --bin /path/to/ec.exe +``` + +The runner defaults to `_build/default/src/ec.exe`, resolved relative +to the repository root. It prints `PASS`/`FAIL` per scenario, a unified +diff for each mismatch, and exits nonzero if anything failed. That is +the CI invocation. + +## Re-recording + +``` +scripts/testing/llm-golden --record # all scenarios +scripts/testing/llm-golden --record load-goals # one scenario +``` + +`--record` overwrites `expected/*.out` with the current binary's +output instead of diffing. It still checks the declared exit status +and reports a mismatch, so a stale `# exit:` line cannot go unnoticed. + +Re-record only deliberately: these goldens are the gate for refactors +of `src/ecLlm.ml`, and every diff must be reviewed by hand. + +## Expected exit status + +Each `.script` declares its expected process exit status on its first +line: + +``` +# exit: 1 +``` + +Lines starting with `#` are comment lines: the runner strips **all** of +them before handing the script to `-eval`, so they can also be used for +prose. The first `# exit: N` line wins; a script without one fails. + +`ec.exe llm -eval` exits 1 if any command produced an `ERROR` reply and +0 otherwise, so scenarios that deliberately exercise error paths +declare `# exit: 1`. That holds however the run ends: `error-exit` +falls off the end of the script, `error-exit-quit` ends on `QUIT` and +`error-exit-phrase` on an `exit.` phrase, and all three declare +`# exit: 1`. + +## Determinism rules + +The goldens are compared byte for byte, so scenarios must not leak +anything machine- or environment-dependent: + +* **Relative paths only.** The runner `cd`s into `tests/llm` before + invoking the binary, and scripts must refer to fixtures as + `LOAD "fixtures/foo.ec"`. `LOAD` echoes the filename verbatim in its + `[loaded:...]` reply tag, so an absolute path would bake the + developer's home directory into the golden. +* **No SMT.** Fixtures and scripts must never use `smt()`, `smt(...)` + or `/#`. Proofs close with `trivial`, `done`, `reflexivity` or + `split`. SMT would make the goldens depend on which provers are + installed, and on their timing. +* **stdout only.** stderr is discarded; only stdout is compared. +* **No `HELP`.** `HELP` echoes `doc/llm/CLAUDE.md`, which would make + every documentation edit a test failure. `envelope-escape` covers the + one property `HELP` would otherwise be needed for — see below. +* Fixtures require `AllCore` only. + +## Body escaping + +The reply frame is a status line, a body, and a lone ``, and the +body is whatever the engine produced: it can perfectly well hold a line +that is itself envelope-shaped, which would close the frame early. +`doc/llm/CLAUDE.md` does exactly that, so `HELP` used to desynchronize +its own reader. + +Call a line *envelope-shaped* when, after dropping any leading spaces, +it is exactly `` or starts with `OK [uuid:`, `ERROR [uuid:` or +`READY [uuid:`. The REPL writes every envelope-shaped **body** line +with one extra leading space; a client drops one leading space from +each envelope-shaped body line it reads, and touches nothing else. +Since leading spaces are part of the test, escaping is idempotent in +the right way — an already-escaped line escapes again — so the rule is +exactly reversible. Status lines are not bodies and are never escaped. + +`scripts/envelope-escape.script` pins this. It loads +`fixtures/envelope.ec` with `-trace`, which echoes the traced +sentence's source verbatim; that sentence hides a bare `` and a +bare `OK [uuid:99]` in a comment. The MCP front-end needs no such rule: +its frame is a JSON string. + +## Strict mode + +`strict-stop` plays the scenario the mode exists for: a phrase fails +after having moved the engine, the phrase after it is refused rather +than run against a state nobody meant, `GOALS` answers all the same, +and `RESUME` releases it. The `COMMIT` at the end is part of the +point — the body it emits carries no trace of either the failed phrase +or the refused one. + +`strict-resume-unstopped` pins the two ways `RESUME` refuses, which +are one mistake seen twice: a client resuming a session that was never +stopped does not know where it is. + +The MCP side has its own `strict-stop`, for the one case a REPL script +cannot show: `ec_try` is refused while stopped like anything else that +would advance, although a failing `ec_try` never stops the session in +the first place. + +## The reload the goldens cannot reach + +The interactive front-ends keep the theories a `require` elaborates +across the scope rebuild a LOAD does, so that reloading a file does not +re-read everything under it. Two halves of that have to be tested, and +only one of them fits here. + +`warm-reload` covers the half that does: it LOADs one fixture twice and +freezes both frames, which must match line for line — the cache is not +supposed to be visible on the wire, and a golden that shows the two +halves side by side is the plainest way to say so. + +The other half is what happens when a file *changes*, and it cannot be +a `-eval` script: the change has to land between two LOADs of one +session, and `-eval` hands the whole script over at once. +`scripts/testing/llm-warm-reload` drives the REPL over stdin instead, +on a fixture tree it writes into a temporary directory, and checks four +scenarios — an untouched reload, an edit to a required file, an edit to +a file reached only through another one, and an include path that +changes so a name resolves elsewhere. Each asserts that the warm +session answers exactly what a cold process answers on the same +sources, and the edited ones also assert the answer moved, so that a +fixture whose edit turns out to be invisible fails instead of passing +without testing anything. + +`make test-llm` runs both. + +## Adding a scenario + +1. Add `scripts/NAME.script` starting with `# exit: N`. +2. Add any new fixture under `fixtures/`. +3. `scripts/testing/llm-golden --record NAME`. +4. Read `expected/NAME.out` and check it is what you meant to freeze. diff --git a/tests/llm/expected/commit-after-qed.out b/tests/llm/expected/commit-after-qed.out new file mode 100644 index 000000000..c55cefa12 --- /dev/null +++ b/tests/llm/expected/commit-after-qed.out @@ -0,0 +1,29 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/simple.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:3] + +OK [uuid:4] [focus: 1/2] + +OK [uuid:5] + +OK [uuid:6] + +OK [uuid:7] +added lemma: `simple_and' + +OK [uuid:7] + +OK [uuid:7] +split. +- trivial. +- trivial. +qed. + diff --git a/tests/llm/expected/commit-focus-order.out b/tests/llm/expected/commit-focus-order.out new file mode 100644 index 000000000..642d4ed36 --- /dev/null +++ b/tests/llm/expected/commit-focus-order.out @@ -0,0 +1,26 @@ +READY [uuid:0] + +OK [uuid:4] [loaded:fixtures/focorder.ec:10] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +x: int +y: int +------------------------------------------------------------------------ +x = x + +OK [uuid:4] + +OK [uuid:5] [focus: 1/2] + +OK [uuid:6] + +OK [uuid:7] + +OK [uuid:7] + +OK [uuid:7] +- by done. +- by rewrite addz0. + diff --git a/tests/llm/expected/commit-load-continuation.out b/tests/llm/expected/commit-load-continuation.out new file mode 100644 index 000000000..d90f6bebd --- /dev/null +++ b/tests/llm/expected/commit-load-continuation.out @@ -0,0 +1,25 @@ +READY [uuid:0] + +OK [uuid:4] [loaded:fixtures/midproof.ec:8] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:4] + +OK [uuid:5] + +OK [uuid:6] + +OK [uuid:6] + +OK [uuid:6] +No more goals + +OK [uuid:6] +- trivial. +- trivial. + diff --git a/tests/llm/expected/commit-nested.out b/tests/llm/expected/commit-nested.out new file mode 100644 index 000000000..0b8a2fa93 --- /dev/null +++ b/tests/llm/expected/commit-nested.out @@ -0,0 +1,37 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/nested.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +((1 = 1 /\ 2 = 2) /\ 3 = 3) /\ 4 = 4 + +OK [uuid:3] + +OK [uuid:4] [focus: 1/2] + +OK [uuid:5] [focus: 1/3] + +OK [uuid:6] [focus: 1/4] + +OK [uuid:7] [focus: 1/3] + +OK [uuid:8] [focus: 1/2] + +OK [uuid:9] + +OK [uuid:10] + +OK [uuid:10] + +OK [uuid:10] +split. +- split. + + split. + * trivial. + * trivial. + + trivial. +- trivial. + diff --git a/tests/llm/expected/commit-simple.out b/tests/llm/expected/commit-simple.out new file mode 100644 index 000000000..3a1e13b34 --- /dev/null +++ b/tests/llm/expected/commit-simple.out @@ -0,0 +1,25 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/simple.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:3] + +OK [uuid:4] [focus: 1/2] + +OK [uuid:5] + +OK [uuid:6] + +OK [uuid:6] + +OK [uuid:6] +split. +- trivial. +- trivial. + diff --git a/tests/llm/expected/commit-strict-bullets.out b/tests/llm/expected/commit-strict-bullets.out new file mode 100644 index 000000000..f844bb224 --- /dev/null +++ b/tests/llm/expected/commit-strict-bullets.out @@ -0,0 +1,25 @@ +READY [uuid:0] + +OK [uuid:6] [loaded:fixtures/strict.ec:11] [focus: 1/3] +Current goal (remaining: 3) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:6] + +OK [uuid:7] [focus: 1/2] + +OK [uuid:8] + +OK [uuid:9] + +OK [uuid:9] + +OK [uuid:9] + + trivial. + + trivial. +- trivial. + diff --git a/tests/llm/expected/commit-strict-nested.out b/tests/llm/expected/commit-strict-nested.out new file mode 100644 index 000000000..2a5a8c31f --- /dev/null +++ b/tests/llm/expected/commit-strict-nested.out @@ -0,0 +1,28 @@ +READY [uuid:0] + +OK [uuid:7] [loaded:fixtures/strictnested.ec:13] [focus: 1/4] +Current goal (remaining: 4) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:7] + +OK [uuid:8] [focus: 1/3] + +OK [uuid:9] [focus: 1/2] + +OK [uuid:10] + +OK [uuid:11] + +OK [uuid:11] + +OK [uuid:11] + * trivial. + * trivial. + + trivial. +- trivial. + diff --git a/tests/llm/expected/commit-two-lemmas-strict.out b/tests/llm/expected/commit-two-lemmas-strict.out new file mode 100644 index 000000000..ae6b6b1ac --- /dev/null +++ b/tests/llm/expected/commit-two-lemmas-strict.out @@ -0,0 +1,48 @@ +READY [uuid:0] + +OK [uuid:6] [loaded:fixtures/strict.ec:11] [focus: 1/3] +Current goal (remaining: 3) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:6] + +OK [uuid:7] [focus: 1/2] + +OK [uuid:8] + +OK [uuid:9] + +OK [uuid:10] +added lemma: `strict_and' + +OK [uuid:11] + +OK [uuid:12] + +OK [uuid:13] [focus: 1/2] + +OK [uuid:14] + +OK [uuid:15] + +OK [uuid:16] +added lemma: `strict_two' + +OK [uuid:16] + +OK [uuid:16] + + trivial. + + trivial. +- trivial. +qed. +lemma strict_two : 3 = 3 /\ 4 = 4. +proof. +split. +- trivial. +- trivial. +qed. + diff --git a/tests/llm/expected/commit-two-lemmas.out b/tests/llm/expected/commit-two-lemmas.out new file mode 100644 index 000000000..6771d6334 --- /dev/null +++ b/tests/llm/expected/commit-two-lemmas.out @@ -0,0 +1,48 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/simple.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:3] + +OK [uuid:4] [focus: 1/2] + +OK [uuid:5] + +OK [uuid:6] + +OK [uuid:7] +added lemma: `simple_and' + +OK [uuid:8] + +OK [uuid:9] + +OK [uuid:10] [focus: 1/2] + +OK [uuid:11] + +OK [uuid:12] + +OK [uuid:13] +added lemma: `two_and' + +OK [uuid:13] + +OK [uuid:13] +split. +- trivial. +- trivial. +qed. +lemma two_and : 3 = 3 /\ 4 = 4. +proof. +split. +- trivial. +- trivial. +qed. + diff --git a/tests/llm/expected/envelope-escape.out b/tests/llm/expected/envelope-escape.out new file mode 100644 index 000000000..816e9b772 --- /dev/null +++ b/tests/llm/expected/envelope-escape.out @@ -0,0 +1,25 @@ +READY [uuid:0] + +OK [uuid:4] [loaded:fixtures/envelope.ec:17] +=== BEFORE: line 12 (col 0) === +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +=== TACTIC (lines 12:0 - 17:8) === +by +(* + + OK [uuid:99] +*) +trivial. + +=== AFTER: line 12 (col 0) === +(no open goals) + +=== SUMMARY === +open goals: 1 -> 0 + diff --git a/tests/llm/expected/error-exit-phrase.out b/tests/llm/expected/error-exit-phrase.out new file mode 100644 index 000000000..6a5e443e7 --- /dev/null +++ b/tests/llm/expected/error-exit-phrase.out @@ -0,0 +1,19 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/simple.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +ERROR [uuid:3] +parse error +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + diff --git a/tests/llm/expected/error-exit-quit.out b/tests/llm/expected/error-exit-quit.out new file mode 100644 index 000000000..8346241ef --- /dev/null +++ b/tests/llm/expected/error-exit-quit.out @@ -0,0 +1,6 @@ +READY [uuid:0] + +ERROR [uuid:0] +nothing to undo +No active proof. + diff --git a/tests/llm/expected/error-exit.out b/tests/llm/expected/error-exit.out new file mode 100644 index 000000000..8346241ef --- /dev/null +++ b/tests/llm/expected/error-exit.out @@ -0,0 +1,6 @@ +READY [uuid:0] + +ERROR [uuid:0] +nothing to undo +No active proof. + diff --git a/tests/llm/expected/focus-nav.out b/tests/llm/expected/focus-nav.out new file mode 100644 index 000000000..c7829c4bd --- /dev/null +++ b/tests/llm/expected/focus-nav.out @@ -0,0 +1,84 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/nested.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +((1 = 1 /\ 2 = 2) /\ 3 = 3) /\ 4 = 4 + +OK [uuid:3] + +OK [uuid:4] [focus: 1/2] + +OK [uuid:5] [focus: 1/3] + +OK [uuid:6] [focus: 1/4] + +OK [uuid:6] + +OK [uuid:6] [focus: 1/4] + [1.1.1] 1 = 1 <- focused + [1.1.2] 2 = 2 + [1.2] 3 = 3 +[2] 4 = 4 + +OK [uuid:7] [focus: 1/4] +Current goal (remaining: 4) + +Type variables: + +------------------------------------------------------------------------ +3 = 3 + +ERROR [uuid:7] +FOCUS: path must select a leaf goal, not a frame +Current goal (remaining: 4) + +Type variables: + +------------------------------------------------------------------------ +3 = 3 + +ERROR [uuid:7] +FOCUS: index 9 out of range (1..2) +Current goal (remaining: 4) + +Type variables: + +------------------------------------------------------------------------ +3 = 3 + +ERROR [uuid:7] +FOCUS: not a path of integers: foo +Current goal (remaining: 4) + +Type variables: + +------------------------------------------------------------------------ +3 = 3 + +ERROR [uuid:7] +FOCUS: missing argument +Current goal (remaining: 4) + +Type variables: + +------------------------------------------------------------------------ +3 = 3 + +OK [uuid:8] [focus: 1/4] +Current goal (remaining: 4) + +Type variables: + +------------------------------------------------------------------------ +4 = 4 + +OK [uuid:8] [focus: 1/4] +[1] 4 = 4 <- focused + [2.1.1] 1 = 1 + [2.1.2] 2 = 2 + [2.2] 3 = 3 + diff --git a/tests/llm/expected/load-errors.out b/tests/llm/expected/load-errors.out new file mode 100644 index 000000000..7628f4c9e --- /dev/null +++ b/tests/llm/expected/load-errors.out @@ -0,0 +1,18 @@ +READY [uuid:0] + +ERROR [uuid:0] +LOAD: missing filename +No active proof. + +ERROR [uuid:0] +LOAD: no such file: fixtures/nosuch.ec +No active proof. + +ERROR [uuid:0] +unknown file extension: .txt +No active proof. + +ERROR [uuid:0] +LOAD: unexpected arguments +No active proof. + diff --git a/tests/llm/expected/load-goals.out b/tests/llm/expected/load-goals.out new file mode 100644 index 000000000..b48f2bebd --- /dev/null +++ b/tests/llm/expected/load-goals.out @@ -0,0 +1,48 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/simple.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:3] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:4] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:4] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:4] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + + + + Goal #2 + ------------------------------------------------------------------------ + 2 = 2 + diff --git a/tests/llm/expected/load-noproof-checked.out b/tests/llm/expected/load-noproof-checked.out new file mode 100644 index 000000000..1cfd33fcf --- /dev/null +++ b/tests/llm/expected/load-noproof-checked.out @@ -0,0 +1,19 @@ +READY [uuid:0] + +OK [uuid:27] [loaded:fixtures/noproof.ec:36] [noproof] +added lemma: `first_and' +added lemma: `second_and' +added lemma: `third_and' +added lemma: `target_and' +No active proof. + +ERROR [uuid:30] +: line 1 (40-44): cannot save an incomplete proof +source: qed. +Current goal + +Type variables: + +------------------------------------------------------------------------ +false + diff --git a/tests/llm/expected/load-noproof-undo.out b/tests/llm/expected/load-noproof-undo.out new file mode 100644 index 000000000..4337ddb08 --- /dev/null +++ b/tests/llm/expected/load-noproof-undo.out @@ -0,0 +1,8 @@ +READY [uuid:0] + +OK [uuid:4] [loaded:fixtures/undoafter.ec:10] +No more goals + +OK [uuid:4] +No more goals + diff --git a/tests/llm/expected/load-noproof.out b/tests/llm/expected/load-noproof.out new file mode 100644 index 000000000..3a05aa911 --- /dev/null +++ b/tests/llm/expected/load-noproof.out @@ -0,0 +1,30 @@ +READY [uuid:0] + +OK [uuid:23] [loaded:fixtures/noproof.ec:32] [noproof] [focus: 1/3] +added lemma: `first_and' +added lemma: `second_and' +added lemma: `third_and' +Current goal (remaining: 3) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:23] [focus: 1/3] + [1.1] 1 = 1 <- focused + [1.2] 2 = 2 +[2] 3 = 3 + +OK [uuid:23] [focus: 1/3] +* In [lemmas or axioms]: + +lemma first_and: 1 = 1 /\ 2 = 2. + +Current goal (remaining: 3) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + diff --git a/tests/llm/expected/load-nosmt.out b/tests/llm/expected/load-nosmt.out new file mode 100644 index 000000000..e08378e82 --- /dev/null +++ b/tests/llm/expected/load-nosmt.out @@ -0,0 +1,18 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/simple.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:3] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + diff --git a/tests/llm/expected/load-trace-notinproof.out b/tests/llm/expected/load-trace-notinproof.out new file mode 100644 index 000000000..345e3ab82 --- /dev/null +++ b/tests/llm/expected/load-trace-notinproof.out @@ -0,0 +1,25 @@ +READY [uuid:0] + +ERROR [uuid:1] +trace: target sentence is not in a proof context +No active proof. + +OK [uuid:1] +No active proof. + +OK [uuid:2] +Current goal + +Type variables: + +------------------------------------------------------------------------ +b2i true = 1 + +OK [uuid:2] +Current goal + +Type variables: + +------------------------------------------------------------------------ +b2i true = 1 + diff --git a/tests/llm/expected/load-trace.out b/tests/llm/expected/load-trace.out new file mode 100644 index 000000000..b424004d7 --- /dev/null +++ b/tests/llm/expected/load-trace.out @@ -0,0 +1,30 @@ +READY [uuid:0] + +OK [uuid:4] [loaded:fixtures/midproof.ec:8] [focus: 1/2] +=== BEFORE: line 8 (col 0) === +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +=== TACTIC (lines 8:0 - 8:6) === +split. + +=== AFTER: line 8 (col 0) === +Type variables: + +------------------------------------------------------------------------ +1 = 1 + + +Type variables: + +------------------------------------------------------------------------ +2 = 2 + + +=== SUMMARY === +open goals: 1 -> 2 + diff --git a/tests/llm/expected/load-upto-undo.out b/tests/llm/expected/load-upto-undo.out new file mode 100644 index 000000000..f49a766c8 --- /dev/null +++ b/tests/llm/expected/load-upto-undo.out @@ -0,0 +1,24 @@ +READY [uuid:0] + +OK [uuid:4] [loaded:fixtures/undoafter.ec:8] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:4] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + + + + Goal #2 + ------------------------------------------------------------------------ + 2 = 2 + diff --git a/tests/llm/expected/loadpath-reset.out b/tests/llm/expected/loadpath-reset.out new file mode 100644 index 000000000..d3e550490 --- /dev/null +++ b/tests/llm/expected/loadpath-reset.out @@ -0,0 +1,16 @@ +READY [uuid:0] + +OK [uuid:1] [loaded:fixtures/sub/entry.ec:2] +No active proof. + +OK [uuid:2] +No active proof. + +OK [uuid:1] [loaded:fixtures/simple.ec:3] +No active proof. + +ERROR [uuid:1] +: line 1 (0-25): cannot locate theory `Neighbour' +source: require import Neighbour. +No active proof. + diff --git a/tests/llm/expected/multi-sentence-error.out b/tests/llm/expected/multi-sentence-error.out new file mode 100644 index 000000000..2bd1cf2bf --- /dev/null +++ b/tests/llm/expected/multi-sentence-error.out @@ -0,0 +1,27 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/simple.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +ERROR [uuid:4] +: line 1 (7-25): unknown lemma `nosuchlemma' +source: apply nosuchlemma. +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:4] [focus: 1/2] +[1] 1 = 1 <- focused +[2] 2 = 2 + +OK [uuid:4] [focus: 1/2] +split. + diff --git a/tests/llm/expected/multi-sentence.out b/tests/llm/expected/multi-sentence.out new file mode 100644 index 000000000..46f132906 --- /dev/null +++ b/tests/llm/expected/multi-sentence.out @@ -0,0 +1,21 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/simple.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:6] +No more goals + +OK [uuid:6] +No more goals + +OK [uuid:6] +split. +- trivial. +- trivial. + diff --git a/tests/llm/expected/multiline.out b/tests/llm/expected/multiline.out new file mode 100644 index 000000000..73772ea35 --- /dev/null +++ b/tests/llm/expected/multiline.out @@ -0,0 +1,37 @@ +READY [uuid:0] + +OK [uuid:1] [loaded:fixtures/simple.ec:3] +No active proof. + +OK [uuid:2] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:2] + +OK [uuid:3] [focus: 1/2] + +OK [uuid:4] + +OK [uuid:5] + +OK [uuid:5] + +OK [uuid:5] +No more goals + +OK [uuid:6] +added lemma: `multi' +No active proof. + +OK [uuid:6] +lemma multi : 1 = 1 /\ 2 = 2. +split. +- trivial. +- trivial. +qed. + diff --git a/tests/llm/expected/print-query.out b/tests/llm/expected/print-query.out new file mode 100644 index 000000000..283a78cb7 --- /dev/null +++ b/tests/llm/expected/print-query.out @@ -0,0 +1,47 @@ +READY [uuid:0] + +OK [uuid:4] [loaded:fixtures/midproof.ec:8] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:4] [focus: 1/2] +* In [operators, predicates or exceptions]: + +(* Int.b2i (shorten name: b2i) *) +op b2i (b : bool) : int = if b then 1 else 0. + +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:4] [focus: 1/2] +In section [operators] + + - Int.b2i (shorten name: b2i) + +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:4] + +OK [uuid:5] + +OK [uuid:6] + +OK [uuid:6] + +OK [uuid:6] +- trivial. +- trivial. + diff --git a/tests/llm/expected/quiet.out b/tests/llm/expected/quiet.out new file mode 100644 index 000000000..6d7186947 --- /dev/null +++ b/tests/llm/expected/quiet.out @@ -0,0 +1,26 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/simple.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:3] + +OK [uuid:4] [focus: 1/2] + +OK [uuid:5] + +OK [uuid:5] + +OK [uuid:5] +Current goal + +Type variables: + +------------------------------------------------------------------------ +2 = 2 + diff --git a/tests/llm/expected/restart-checkpoint.out b/tests/llm/expected/restart-checkpoint.out new file mode 100644 index 000000000..75439f3f4 --- /dev/null +++ b/tests/llm/expected/restart-checkpoint.out @@ -0,0 +1,28 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/simple.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:4] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:4] +checkpoint 'c' set at uuid 4 + +OK [uuid:0] +Session restarted + +ERROR [uuid:0] +REVERT: 'c' is not a valid uuid or checkpoint name +No active proof. + diff --git a/tests/llm/expected/search-in-proof.out b/tests/llm/expected/search-in-proof.out new file mode 100644 index 000000000..e2d3a9984 --- /dev/null +++ b/tests/llm/expected/search-in-proof.out @@ -0,0 +1,40 @@ +READY [uuid:0] + +OK [uuid:4] [loaded:fixtures/midproof.ec:8] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:4] + +OK [uuid:5] + +OK [uuid:5] +(* RField.signr_odd *) +lemma signr_odd: + forall (n : int), 0 <= n => (- 1%r) ^ b2i (odd n) = (- 1%r) ^ n. +lemma b2rE: forall (b : bool), b2r b = (b2i b)%r. +lemma le_b2i: forall (b1 b2 : bool), (b1 => b2) <=> b2i b1 <= b2i b2. +lemma b2i_or: + forall (b1 b2 : bool), b2i (b1 \/ b2) = b2i b1 + b2i b2 - b2i b1 * b2i b2. +lemma b2i_le1: forall (b : bool), b2i b <= 1. +lemma b2i_ge0: forall (b : bool), 0 <= b2i b. +lemma b2i_eq1: forall (b : bool), b2i b = 1 <=> b. +lemma b2i_eq0: forall (b : bool), b2i b = 0 <=> !b. +lemma b2i_and: forall (b1 b2 : bool), b2i (b1 /\ b2) = b2i b1 * b2i b2. +lemma b2i1: b2i true = 1. +lemma b2i0: b2i false = 0. +lemma signr_odd: forall (n : int), 0 <= n => (-1) ^ b2i (odd n) = (-1) ^ n. + + +OK [uuid:6] + +OK [uuid:6] + +OK [uuid:6] +- trivial. +- trivial. + diff --git a/tests/llm/expected/search-injection.out b/tests/llm/expected/search-injection.out new file mode 100644 index 000000000..009dc8ba5 --- /dev/null +++ b/tests/llm/expected/search-injection.out @@ -0,0 +1,53 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/simple.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +ERROR [uuid:3] +SEARCH: the argument must be a single search pattern +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:3] + +OK [uuid:3] +(* RField.ofintS *) +lemma ofintS: + forall (i : int), 0 <= i => RField.ofint (i + 1) = 1%r + RField.ofint i. +(* RField.ofintR *) +lemma ofintR: forall (i : int), RField.ofint i = i%r. +(* RField.ofintN *) +lemma ofintN: forall (i : int), RField.ofint (-i) = - RField.ofint i. +(* RField.ofint1 *) +lemma ofint1: RField.ofint 1 = 1%r. +(* RField.ofint0 *) +lemma ofint0: RField.ofint 0 = 0%r. +(* RField.mulr_intr *) +lemma mulr_intr: + forall (x : real) (z : int), x * RField.ofint z = RField.intmul x z. +(* RField.mulr_intl *) +lemma mulr_intl: + forall (x : real) (z : int), RField.ofint z * x = RField.intmul x z. +(* RField.mul1r2z *) +lemma mul1r2z: forall (x : real), x * RField.ofint 2 = x + x. +(* RField.mul1r1z *) +lemma mul1r1z: forall (x : real), x * RField.ofint 1 = x. +(* RField.mul1r0z *) +lemma mul1r0z: forall (x : real), x * RField.ofint 0 = 0%r. + +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + diff --git a/tests/llm/expected/search.out b/tests/llm/expected/search.out new file mode 100644 index 000000000..055e55810 --- /dev/null +++ b/tests/llm/expected/search.out @@ -0,0 +1,43 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/simple.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:3] +(* RField.signr_odd *) +lemma signr_odd: + forall (n : int), 0 <= n => (- 1%r) ^ b2i (odd n) = (- 1%r) ^ n. +lemma b2rE: forall (b : bool), b2r b = (b2i b)%r. +lemma le_b2i: forall (b1 b2 : bool), (b1 => b2) <=> b2i b1 <= b2i b2. +lemma b2i_or: + forall (b1 b2 : bool), b2i (b1 \/ b2) = b2i b1 + b2i b2 - b2i b1 * b2i b2. +lemma b2i_le1: forall (b : bool), b2i b <= 1. +lemma b2i_ge0: forall (b : bool), 0 <= b2i b. +lemma b2i_eq1: forall (b : bool), b2i b = 1 <=> b. +lemma b2i_eq0: forall (b : bool), b2i b = 0 <=> !b. +lemma b2i_and: forall (b1 b2 : bool), b2i (b1 /\ b2) = b2i b1 * b2i b2. +lemma b2i1: b2i true = 1. +lemma b2i0: b2i false = 0. +lemma signr_odd: forall (n : int), 0 <= n => (-1) ^ b2i (odd n) = (-1) ^ n. + +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +ERROR [uuid:3] +SEARCH: missing query +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + diff --git a/tests/llm/expected/strict-resume-unstopped.out b/tests/llm/expected/strict-resume-unstopped.out new file mode 100644 index 000000000..5495062dd --- /dev/null +++ b/tests/llm/expected/strict-resume-unstopped.out @@ -0,0 +1,29 @@ +READY [uuid:0] + +ERROR [uuid:0] +RESUME: strict mode is off +No active proof. + +OK [uuid:0] +strict: on -- a failure stops the session until UNDO, REVERT, LOAD or RESUME + +OK [uuid:2] [loaded:fixtures/simple.ec:5] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +ERROR [uuid:2] +RESUME: the session is not stopped +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:2] +strict: off + diff --git a/tests/llm/expected/strict-stop.out b/tests/llm/expected/strict-stop.out new file mode 100644 index 000000000..4503070f2 --- /dev/null +++ b/tests/llm/expected/strict-stop.out @@ -0,0 +1,74 @@ +READY [uuid:0] + +OK [uuid:0] +strict: on -- a failure stops the session until UNDO, REVERT, LOAD or RESUME + +OK [uuid:2] [loaded:fixtures/simple.ec:5] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:3] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +ERROR [uuid:3] +: line 1 (0-15): unknown lemma `etrivial' +source: apply etrivial. +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +ERROR [uuid:3] +strict: the session stopped at a failed phrase and has not been resynchronized +stopped at: apply etrivial. +UNDO, REVERT, LOAD or RESUME to continue; GOALS, TREE, SEARCH and COMMIT answer meanwhile +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:3] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:3] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:4] +Current goal + +Type variables: + +------------------------------------------------------------------------ +2 = 2 + +OK [uuid:5] +No more goals + +OK [uuid:5] +split. +- trivial. +- trivial. + diff --git a/tests/llm/expected/tree-nested.out b/tests/llm/expected/tree-nested.out new file mode 100644 index 000000000..d8936191a --- /dev/null +++ b/tests/llm/expected/tree-nested.out @@ -0,0 +1,52 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/nested.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +((1 = 1 /\ 2 = 2) /\ 3 = 3) /\ 4 = 4 + +OK [uuid:3] + +OK [uuid:4] [focus: 1/2] + +OK [uuid:5] [focus: 1/3] + +OK [uuid:6] [focus: 1/4] + +OK [uuid:6] + +OK [uuid:6] [focus: 1/4] + [1.1.1] 1 = 1 <- focused + [1.1.2] 2 = 2 + [1.2] 3 = 3 +[2] 4 = 4 + +OK [uuid:6] [focus: 1/4] + [1.1.1] <- focused +Type variables: + +------------------------------------------------------------------------ +1 = 1 + + [1.1.2] +Type variables: + +------------------------------------------------------------------------ +2 = 2 + + [1.2] +Type variables: + +------------------------------------------------------------------------ +3 = 3 + +[2] +Type variables: + +------------------------------------------------------------------------ +4 = 4 + + diff --git a/tests/llm/expected/undo-revert.out b/tests/llm/expected/undo-revert.out new file mode 100644 index 000000000..e00e426a8 --- /dev/null +++ b/tests/llm/expected/undo-revert.out @@ -0,0 +1,105 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/simple.ec:6] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:3] +checkpoint 'start' set at uuid 3 + +OK [uuid:4] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:3] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:4] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:5] +Current goal + +Type variables: + +------------------------------------------------------------------------ +2 = 2 + +OK [uuid:4] [focus: 1/2] +Current goal (remaining: 2) + +Type variables: + +------------------------------------------------------------------------ +1 = 1 + +OK [uuid:3] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +ERROR [uuid:3] +CHECKPOINT: missing name +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +ERROR [uuid:3] +REVERT: missing uuid or checkpoint name +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +OK [uuid:3] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +ERROR [uuid:3] +REVERT: 'nosuch' is not a valid uuid or checkpoint name +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + +ERROR [uuid:3] +REVERT: uuid 999 out of range [0, 3] +Current goal + +Type variables: + +------------------------------------------------------------------------ +1 = 1 /\ 2 = 2 + diff --git a/tests/llm/expected/warm-reload.out b/tests/llm/expected/warm-reload.out new file mode 100644 index 000000000..54f824cc9 --- /dev/null +++ b/tests/llm/expected/warm-reload.out @@ -0,0 +1,44 @@ +READY [uuid:0] + +OK [uuid:3] [loaded:fixtures/sub/reload.ec:7] +Current goal + +Type variables: + +------------------------------------------------------------------------ +neighbour = 3 + +OK [uuid:3] +* In [operators, predicates or exceptions]: + +(* Neighbour.neighbour (shorten name: neighbour) *) +op neighbour : int = 3. + +Current goal + +Type variables: + +------------------------------------------------------------------------ +neighbour = 3 + +OK [uuid:3] [loaded:fixtures/sub/reload.ec:7] +Current goal + +Type variables: + +------------------------------------------------------------------------ +neighbour = 3 + +OK [uuid:3] +* In [operators, predicates or exceptions]: + +(* Neighbour.neighbour (shorten name: neighbour) *) +op neighbour : int = 3. + +Current goal + +Type variables: + +------------------------------------------------------------------------ +neighbour = 3 + diff --git a/tests/llm/fixtures/envelope.ec b/tests/llm/fixtures/envelope.ec new file mode 100644 index 000000000..ddd667661 --- /dev/null +++ b/tests/llm/fixtures/envelope.ec @@ -0,0 +1,18 @@ +(* The tactic below spans several lines, one of which is exactly the + `' sentinel and another of which is shaped like a status line. + `LOAD -trace' echoes a sentence's source verbatim, so this is the + cheapest way to get envelope-shaped text into a reply body without + using HELP (which the goldens may not call: it would turn every + documentation edit into a test failure). Both lines must come back + escaped with one leading space. *) +require import AllCore. + +lemma envelope : 1 = 1. +proof. +by +(* + +OK [uuid:99] +*) +trivial. +qed. diff --git a/tests/llm/fixtures/focorder.ec b/tests/llm/fixtures/focorder.ec new file mode 100644 index 000000000..54f4f4626 --- /dev/null +++ b/tests/llm/fixtures/focorder.ec @@ -0,0 +1,10 @@ +(* Deliberately truncated, like fixtures/midproof.ec: the file ends on + `split.`, so a bare LOAD lands with two open goals. The two goals + need *different* tactics and neither closes the other, so a COMMIT + that emits them in the wrong order produces a body that does not + replay. *) +require import AllCore. + +lemma focus_order (x y : int) : x = x /\ y + 0 = y. +proof. +split. diff --git a/tests/llm/fixtures/latin1.ec b/tests/llm/fixtures/latin1.ec new file mode 100644 index 000000000..6e703ef13 --- /dev/null +++ b/tests/llm/fixtures/latin1.ec @@ -0,0 +1,13 @@ +(* Not UTF-8: the comment below is Latin-1, and it sits *inside* the + traced sentence, so `LOAD -trace' echoes its bytes back verbatim. + The MCP front-end must repair them before they reach a JSON string; + the REPL, whose frame is bytes, passes them through. Keep this file + in Latin-1 -- re-encoding it to UTF-8 makes the test vacuous. *) +require import AllCore. + +lemma latin1 : 1 = 1. +proof. +by +(* dcoupe en rgions *) +trivial. +qed. diff --git a/tests/llm/fixtures/midproof.ec b/tests/llm/fixtures/midproof.ec new file mode 100644 index 000000000..6b2bad195 --- /dev/null +++ b/tests/llm/fixtures/midproof.ec @@ -0,0 +1,8 @@ +(* Deliberately truncated: the file ends inside the proof, so a bare + `LOAD "fixtures/midproof.ec"` lands mid-proof with two open goals. + Used for the LOAD-continuation and -trace scenarios. *) +require import AllCore. + +lemma cont_and : 1 = 1 /\ 2 = 2. +proof. +split. diff --git a/tests/llm/fixtures/nested.ec b/tests/llm/fixtures/nested.ec new file mode 100644 index 000000000..b30ba0234 --- /dev/null +++ b/tests/llm/fixtures/nested.ec @@ -0,0 +1,14 @@ +(* Nested conjunction: `split. split. split.` from the state at line 6 + opens four goals nested as [1.1.1] [1.1.2] [1.2] [2]. *) +require import AllCore. + +lemma nested_and : ((1 = 1 /\ 2 = 2) /\ 3 = 3) /\ 4 = 4. +proof. +split. +split. +split. +trivial. +trivial. +trivial. +trivial. +qed. diff --git a/tests/llm/fixtures/noproof.ec b/tests/llm/fixtures/noproof.ec new file mode 100644 index 000000000..8d01255ae --- /dev/null +++ b/tests/llm/fixtures/noproof.ec @@ -0,0 +1,36 @@ +(* Three lemmas whose proofs `LOAD -noproof' skips -- each is admitted + on its statement alone -- and a fourth one the LOAD position lands + inside, whose script is replayed for real. `first_and' is the one + the scripts `print' afterwards, to show a skipped lemma is bound and + usable all the same. *) +require import AllCore. + +lemma first_and : 1 = 1 /\ 2 = 2. +proof. +split. +trivial. +trivial. +qed. + +lemma second_and : 3 = 3 /\ 4 = 4. +proof. +split. +trivial. +trivial. +qed. + +lemma third_and : 5 = 5 /\ 6 = 6. +proof. +split. +trivial. +trivial. +qed. + +lemma target_and : (1 = 1 /\ 2 = 2) /\ 3 = 3. +proof. +split. +split. +trivial. +trivial. +trivial. +qed. diff --git a/tests/llm/fixtures/notec.txt b/tests/llm/fixtures/notec.txt new file mode 100644 index 000000000..a3de7e3c6 --- /dev/null +++ b/tests/llm/fixtures/notec.txt @@ -0,0 +1,2 @@ +This file exists but is not an EasyCrypt source: LOAD must reject it +with the unknown-extension error, not with the missing-file error. diff --git a/tests/llm/fixtures/simple.ec b/tests/llm/fixtures/simple.ec new file mode 100644 index 000000000..ceb2d9c37 --- /dev/null +++ b/tests/llm/fixtures/simple.ec @@ -0,0 +1,10 @@ +(* Simple conjunction: LOAD stops on line 5 (the `proof.`), leaving one + open goal `1 = 1 /\ 2 = 2`. *) +require import AllCore. + +lemma simple_and : 1 = 1 /\ 2 = 2. +proof. +split. +trivial. +trivial. +qed. diff --git a/tests/llm/fixtures/strict.ec b/tests/llm/fixtures/strict.ec new file mode 100644 index 000000000..6d0948646 --- /dev/null +++ b/tests/llm/fixtures/strict.ec @@ -0,0 +1,11 @@ +(* Deliberately truncated, under +strict_bullets: the LOAD prefix leaves + the bullet stack holding `-`, so COMMIT must pick a different token + for the bullets it emits. *) +pragma +strict_bullets. + +require import AllCore. + +lemma strict_and : (1 = 1 /\ 2 = 2) /\ 3 = 3. +proof. +split. +- split. diff --git a/tests/llm/fixtures/strictnested.ec b/tests/llm/fixtures/strictnested.ec new file mode 100644 index 000000000..3dccf4d83 --- /dev/null +++ b/tests/llm/fixtures/strictnested.ec @@ -0,0 +1,13 @@ +(* Deliberately truncated, under +strict_bullets: the LOAD prefix leaves + two frames on the bullet stack (`-` outermost, `+` inside it) and + four open goals. COMMIT must address the goals still owned by those + frames with the frames' own tokens, and open one fresh level. *) +pragma +strict_bullets. + +require import AllCore. + +lemma strict_nested : ((1 = 1 /\ 2 = 2) /\ 3 = 3) /\ 4 = 4. +proof. +split. +- split. + + split. diff --git a/tests/llm/fixtures/sub/Neighbour.ec b/tests/llm/fixtures/sub/Neighbour.ec new file mode 100644 index 000000000..186cf91f3 --- /dev/null +++ b/tests/llm/fixtures/sub/Neighbour.ec @@ -0,0 +1,5 @@ +(* A theory that exists only in this subdirectory. It is reachable from + fixtures/sub/entry.ec, its neighbour, and must be reachable from + nowhere else: a LOAD of a file in another directory has to leave the + include path with no memory of this one. *) +op neighbour : int = 3. diff --git a/tests/llm/fixtures/sub/entry.ec b/tests/llm/fixtures/sub/entry.ec new file mode 100644 index 000000000..e520b6f62 --- /dev/null +++ b/tests/llm/fixtures/sub/entry.ec @@ -0,0 +1,2 @@ +(* Loaded first, only so that its directory joins the include path. *) +require import AllCore. diff --git a/tests/llm/fixtures/sub/reload.ec b/tests/llm/fixtures/sub/reload.ec new file mode 100644 index 000000000..606d96fcd --- /dev/null +++ b/tests/llm/fixtures/sub/reload.ec @@ -0,0 +1,10 @@ +(* LOADed twice in one session, to pin what a reload costs nothing: + `Neighbour' is elaborated once and served from the theory cache the + second time, and the two replies have to be indistinguishable. *) +require import AllCore Neighbour. + +lemma warm : neighbour = 3. +proof. +rewrite /neighbour. +trivial. +qed. diff --git a/tests/llm/fixtures/undoafter.ec b/tests/llm/fixtures/undoafter.ec new file mode 100644 index 000000000..bea4944cd --- /dev/null +++ b/tests/llm/fixtures/undoafter.ec @@ -0,0 +1,10 @@ +(* An `undo` sits on line 9, right after the line a LOAD stops at. + Stopping at line 8 must leave the two goals `split.` opened: the + `undo` is past the stop point and must not run. *) +require import AllCore. + +lemma undo_after : 1 = 1 /\ 2 = 2. +proof. +split. +undo 3. +trivial. diff --git a/tests/llm/scripts/commit-after-qed.script b/tests/llm/scripts/commit-after-qed.script new file mode 100644 index 000000000..48e5c1493 --- /dev/null +++ b/tests/llm/scripts/commit-after-qed.script @@ -0,0 +1,13 @@ +# exit: 0 +# COMMIT run after `qed.`: the active proof is gone, but COMMIT queries +# the proofenv snapshot taken at the last recorded phrase, so the body +# still carries bullets. `qed.` itself stays flat (no goal was open +# right before it, hence no parent handle). +LOAD "fixtures/simple.ec" 6 +QUIET ON +split. +trivial. +trivial. +qed. +QUIET OFF +COMMIT diff --git a/tests/llm/scripts/commit-focus-order.script b/tests/llm/scripts/commit-focus-order.script new file mode 100644 index 000000000..ade7c5637 --- /dev/null +++ b/tests/llm/scripts/commit-focus-order.script @@ -0,0 +1,14 @@ +# exit: 0 +# COMMIT emits sibling subtrees in DAG order, not in typing order. +# FOCUS 2 jumps to the second subgoal, which is discharged first; the +# body COMMIT prints must still open with the first subgoal's tactic, +# because a proof body replays top to bottom. Emitting the phrases in +# the order they were typed produced `- by rewrite addz0.' first, and +# pasting that under `split.' failed with "nothing to rewrite". +LOAD "fixtures/focorder.ec" +QUIET ON +FOCUS 2 +by rewrite addz0. +by done. +QUIET OFF +COMMIT diff --git a/tests/llm/scripts/commit-load-continuation.script b/tests/llm/scripts/commit-load-continuation.script new file mode 100644 index 000000000..fcd9a3c1b --- /dev/null +++ b/tests/llm/scripts/commit-load-continuation.script @@ -0,0 +1,9 @@ +# exit: 0 +# LOAD a file that ends mid-proof, continue it at the REPL, COMMIT. +LOAD "fixtures/midproof.ec" +QUIET ON +trivial. +trivial. +QUIET OFF +GOALS +COMMIT diff --git a/tests/llm/scripts/commit-nested.script b/tests/llm/scripts/commit-nested.script new file mode 100644 index 000000000..175da21f8 --- /dev/null +++ b/tests/llm/scripts/commit-nested.script @@ -0,0 +1,13 @@ +# exit: 0 +# COMMIT after nested splits: bullets nest as - / + / *. +LOAD "fixtures/nested.ec" 6 +QUIET ON +split. +split. +split. +trivial. +trivial. +trivial. +trivial. +QUIET OFF +COMMIT diff --git a/tests/llm/scripts/commit-simple.script b/tests/llm/scripts/commit-simple.script new file mode 100644 index 000000000..7dd1fead9 --- /dev/null +++ b/tests/llm/scripts/commit-simple.script @@ -0,0 +1,9 @@ +# exit: 0 +# COMMIT after a plain split + two trivials. +LOAD "fixtures/simple.ec" 6 +QUIET ON +split. +trivial. +trivial. +QUIET OFF +COMMIT diff --git a/tests/llm/scripts/commit-strict-bullets.script b/tests/llm/scripts/commit-strict-bullets.script new file mode 100644 index 000000000..8912983aa --- /dev/null +++ b/tests/llm/scripts/commit-strict-bullets.script @@ -0,0 +1,11 @@ +# exit: 0 +# The LOAD prefix of fixtures/strict.ec ends under `pragma +# +strict_bullets` with `-` on the bullet stack, so COMMIT must pick a +# token other than `-`. +LOAD "fixtures/strict.ec" +QUIET ON +trivial. +trivial. +trivial. +QUIET OFF +COMMIT diff --git a/tests/llm/scripts/commit-strict-nested.script b/tests/llm/scripts/commit-strict-nested.script new file mode 100644 index 000000000..c1fd92099 --- /dev/null +++ b/tests/llm/scripts/commit-strict-nested.script @@ -0,0 +1,13 @@ +# exit: 0 +# The LOAD prefix of fixtures/strictnested.ec stops under two open +# bullet frames (`-` then `+`) with four goals open. COMMIT must reuse +# `-` for the outer frame's next sibling, `+` for the inner frame's, +# and pick `*` fresh for the level the prefix never opened. +LOAD "fixtures/strictnested.ec" +QUIET ON +trivial. +trivial. +trivial. +trivial. +QUIET OFF +COMMIT diff --git a/tests/llm/scripts/commit-two-lemmas-strict.script b/tests/llm/scripts/commit-two-lemmas-strict.script new file mode 100644 index 000000000..38140bd8d --- /dev/null +++ b/tests/llm/scripts/commit-two-lemmas-strict.script @@ -0,0 +1,19 @@ +# exit: 0 +# The same, but the first proof continues the LOAD prefix's own bullet +# stack (fixtures/strict.ec, under `pragma +strict_bullets`). The +# second lemma opens no such frame, so it must start again at depth 0 +# with the first token, not inherit the prefix's depth or its tokens. +LOAD "fixtures/strict.ec" +QUIET ON +trivial. +trivial. +trivial. +qed. +lemma strict_two : 3 = 3 /\ 4 = 4. +proof. +split. +trivial. +trivial. +qed. +QUIET OFF +COMMIT diff --git a/tests/llm/scripts/commit-two-lemmas.script b/tests/llm/scripts/commit-two-lemmas.script new file mode 100644 index 000000000..d89b204b9 --- /dev/null +++ b/tests/llm/scripts/commit-two-lemmas.script @@ -0,0 +1,19 @@ +# exit: 0 +# Two complete lemmas in one session. Bullet structure and indentation +# are per proof: the first lemma keeps its bullets once the second is +# started, and the second neither inherits the first's depth nor its +# reserved tokens. +LOAD "fixtures/simple.ec" 6 +QUIET ON +split. +trivial. +trivial. +qed. +lemma two_and : 3 = 3 /\ 4 = 4. +proof. +split. +trivial. +trivial. +qed. +QUIET OFF +COMMIT diff --git a/tests/llm/scripts/envelope-escape.script b/tests/llm/scripts/envelope-escape.script new file mode 100644 index 000000000..68043f8be --- /dev/null +++ b/tests/llm/scripts/envelope-escape.script @@ -0,0 +1,8 @@ +# exit: 0 +# A reply body may hold a line that is itself envelope-shaped, which +# would close the frame early. `LOAD -trace' echoes the traced +# sentence's source verbatim, and fixtures/envelope.ec hides a bare +# `' and a bare `OK [uuid:99]' inside it. Both must come out with +# one extra leading space, leaving exactly one unescaped `' in the +# frame -- the sentinel. +LOAD "fixtures/envelope.ec" 17 -trace diff --git a/tests/llm/scripts/error-exit-phrase.script b/tests/llm/scripts/error-exit-phrase.script new file mode 100644 index 000000000..699f8beca --- /dev/null +++ b/tests/llm/scripts/error-exit-phrase.script @@ -0,0 +1,8 @@ +# exit: 1 +# An `exit.` phrase ends the session too, and likewise must not swallow +# the ERROR that came before it. As above, the trailing GOALS is there +# to show the session did stop at `exit.`. +LOAD "fixtures/simple.ec" 6 +nosuchtactic. +exit. +GOALS diff --git a/tests/llm/scripts/error-exit-quit.script b/tests/llm/scripts/error-exit-quit.script new file mode 100644 index 000000000..c72a25787 --- /dev/null +++ b/tests/llm/scripts/error-exit-quit.script @@ -0,0 +1,8 @@ +# exit: 1 +# The same error, followed by QUIT -- the natural way to end a script. +# QUIT leaves through the same exit-status logic as end of input, so +# the ERROR is still reported. The trailing GOALS proves QUIT really +# ended the session: its reply is absent from the golden. +UNDO +QUIT +GOALS diff --git a/tests/llm/scripts/error-exit.script b/tests/llm/scripts/error-exit.script new file mode 100644 index 000000000..01bb443d2 --- /dev/null +++ b/tests/llm/scripts/error-exit.script @@ -0,0 +1,3 @@ +# exit: 1 +# A script whose only command errors: the process must exit 1. +UNDO diff --git a/tests/llm/scripts/focus-nav.script b/tests/llm/scripts/focus-nav.script new file mode 100644 index 000000000..54963c73e --- /dev/null +++ b/tests/llm/scripts/focus-nav.script @@ -0,0 +1,17 @@ +# exit: 1 +# FOCUS with a dotted path, on a frame (error), out of range (error), +# with a non-integer path (parse error), then NEXT. +LOAD "fixtures/nested.ec" 6 +QUIET ON +split. +split. +split. +QUIET OFF +TREE +FOCUS 1.2 +FOCUS 1 +FOCUS 9 +FOCUS foo +FOCUS +NEXT +TREE diff --git a/tests/llm/scripts/load-errors.script b/tests/llm/scripts/load-errors.script new file mode 100644 index 000000000..66338b489 --- /dev/null +++ b/tests/llm/scripts/load-errors.script @@ -0,0 +1,7 @@ +# exit: 1 +# LOAD argument errors: missing filename, missing file, an existing +# file with an unknown extension, trailing junk. +LOAD +LOAD "fixtures/nosuch.ec" +LOAD "fixtures/notec.txt" +LOAD "fixtures/simple.ec" 6 7 diff --git a/tests/llm/scripts/load-goals.script b/tests/llm/scripts/load-goals.script new file mode 100644 index 000000000..83d64c2b8 --- /dev/null +++ b/tests/llm/scripts/load-goals.script @@ -0,0 +1,7 @@ +# exit: 0 +# LOAD a file up to a proof point, then inspect with GOALS / GOALS ALL. +LOAD "fixtures/simple.ec" 6 +GOALS +split. +GOALS +GOALS ALL diff --git a/tests/llm/scripts/load-noproof-checked.script b/tests/llm/scripts/load-noproof-checked.script new file mode 100644 index 000000000..f8806b103 --- /dev/null +++ b/tests/llm/scripts/load-noproof-checked.script @@ -0,0 +1,16 @@ +# exit: 1 +# Two properties of -noproof past the prefix it skipped. +# +# First, a position outside any proof (the whole file here) skips every +# proof in it: the reply is tagged [noproof] and leaves no active proof. +# +# Second, skipping ends with the LOAD. Proof checking is restored on +# the way out, so the phrase typed next is checked for real and its +# `qed.' is refused -- which is what makes the exit status 1. +LOAD "fixtures/noproof.ec" -noproof + +lemma unproved : false. +proof. +trivial. +qed. + diff --git a/tests/llm/scripts/load-noproof-undo.script b/tests/llm/scripts/load-noproof-undo.script new file mode 100644 index 000000000..202072f0a --- /dev/null +++ b/tests/llm/scripts/load-noproof-undo.script @@ -0,0 +1,11 @@ +# exit: 0 +# -noproof gives up rather than guess. Deciding which proof to replay +# is a parse-only pass over the prefix, and an `undo' in it moves the +# engine in a way that pass cannot follow without running the file. So +# a prefix holding one is loaded with checking on throughout: slower, +# never wrong. The tell is the reply tag, which carries no [noproof] +# here -- `fixtures/undoafter.ec' has an `undo 3.' on line 9, and +# stopping at line 10 puts it inside the prefix. `load-upto-undo' pins +# the same file stopping short of the `undo'. +LOAD "fixtures/undoafter.ec" 10 -noproof +GOALS diff --git a/tests/llm/scripts/load-noproof.script b/tests/llm/scripts/load-noproof.script new file mode 100644 index 000000000..9cb6b0d2b --- /dev/null +++ b/tests/llm/scripts/load-noproof.script @@ -0,0 +1,11 @@ +# exit: 0 +# -noproof admits every lemma before the target on its statement alone +# and replays only the proof the position lands inside. Line 32 is the +# second `split.' of `target_and', so the goals here are exactly the +# ones `load-goals'-style plain LOAD reports at that line -- run the +# same LOAD without the flag to see it. The reply tag carries +# [noproof], and `print first_and' shows a skipped lemma is bound and +# usable like any other. +LOAD "fixtures/noproof.ec" 32 -noproof +TREE +print first_and. diff --git a/tests/llm/scripts/load-nosmt.script b/tests/llm/scripts/load-nosmt.script new file mode 100644 index 000000000..deb495e0b --- /dev/null +++ b/tests/llm/scripts/load-nosmt.script @@ -0,0 +1,4 @@ +# exit: 0 +# LOAD -nosmt just has to load. +LOAD "fixtures/simple.ec" 6 -nosmt +GOALS diff --git a/tests/llm/scripts/load-trace-notinproof.script b/tests/llm/scripts/load-trace-notinproof.script new file mode 100644 index 000000000..4022dc223 --- /dev/null +++ b/tests/llm/scripts/load-trace-notinproof.script @@ -0,0 +1,9 @@ +# exit: 1 +# LOAD -trace whose target sentence is outside any proof. Tracing +# fails, but the prefix must be in effect exactly as after a plain +# LOAD: the deferred `require import AllCore.` has run, so `b2i' below +# resolves and GOALS shows its goal. +LOAD "fixtures/simple.ec" 3 -trace +GOALS +lemma preserved : b2i true = 1. +GOALS diff --git a/tests/llm/scripts/load-trace.script b/tests/llm/scripts/load-trace.script new file mode 100644 index 000000000..b02e820bc --- /dev/null +++ b/tests/llm/scripts/load-trace.script @@ -0,0 +1,3 @@ +# exit: 0 +# LOAD -trace on a file ending mid-proof: BEFORE/TACTIC/AFTER/SUMMARY. +LOAD "fixtures/midproof.ec" -trace diff --git a/tests/llm/scripts/load-upto-undo.script b/tests/llm/scripts/load-upto-undo.script new file mode 100644 index 000000000..c2fea9828 --- /dev/null +++ b/tests/llm/scripts/load-upto-undo.script @@ -0,0 +1,8 @@ +# exit: 0 +# LOAD stops at the requested position whatever sentence sits after it. +# fixtures/undoafter.ec has `undo 3.` on the line following the stop +# point: the two goals `split.` opened must still be there, and the +# uuid must be the one that prefix reaches -- not the lower one the +# `undo` would rewind to. +LOAD "fixtures/undoafter.ec" 8 +GOALS ALL diff --git a/tests/llm/scripts/loadpath-reset.script b/tests/llm/scripts/loadpath-reset.script new file mode 100644 index 000000000..b1fdc420b --- /dev/null +++ b/tests/llm/scripts/loadpath-reset.script @@ -0,0 +1,11 @@ +# exit: 1 +# The include path is process-global and grows with every LOAD, so a +# previously loaded file's directory used to stay searchable for later, +# unrelated LOADs. Here `Neighbour' lives next to fixtures/sub/entry.ec +# and nowhere else: it must resolve while that file is the session, and +# stop resolving once fixtures/simple.ec is (loaded up to its own +# `require', so that the second attempt is outside any proof). +LOAD "fixtures/sub/entry.ec" +require import Neighbour. +LOAD "fixtures/simple.ec" 3 +require import Neighbour. diff --git a/tests/llm/scripts/multi-sentence-error.script b/tests/llm/scripts/multi-sentence-error.script new file mode 100644 index 000000000..3471c6909 --- /dev/null +++ b/tests/llm/scripts/multi-sentence-error.script @@ -0,0 +1,10 @@ +# exit: 1 +# File semantics for a multi-sentence line: the sentences before the +# failing one stay applied. `split.' succeeds, `apply nosuchlemma.' +# fails, and the trailing `trivial.' never runs -- so the session is +# left with the two goals `split.' opened, and COMMIT holds `split.' +# alone. +LOAD "fixtures/simple.ec" 6 +split. apply nosuchlemma. trivial. +TREE +COMMIT diff --git a/tests/llm/scripts/multi-sentence.script b/tests/llm/scripts/multi-sentence.script new file mode 100644 index 000000000..29efb00d1 --- /dev/null +++ b/tests/llm/scripts/multi-sentence.script @@ -0,0 +1,7 @@ +# exit: 0 +# Several sentences on one line: every one of them runs, and a single +# reply describes the state they leave behind. COMMIT records all three. +LOAD "fixtures/simple.ec" 6 +split. trivial. trivial. +GOALS +COMMIT diff --git a/tests/llm/scripts/multiline.script b/tests/llm/scripts/multiline.script new file mode 100644 index 000000000..32299a6b0 --- /dev/null +++ b/tests/llm/scripts/multiline.script @@ -0,0 +1,16 @@ +# exit: 0 +# / multi-line EasyCrypt input. +LOAD "fixtures/simple.ec" 3 + +lemma multi : + 1 = 1 /\ + 2 = 2. + +QUIET ON +split. +trivial. +trivial. +QUIET OFF +GOALS +qed. +COMMIT diff --git a/tests/llm/scripts/print-query.script b/tests/llm/scripts/print-query.script new file mode 100644 index 000000000..5d2798abe --- /dev/null +++ b/tests/llm/scripts/print-query.script @@ -0,0 +1,14 @@ +# exit: 0 +# `print' renders inside the reply frame. It used to write straight to +# the process's stdout, so its output came out *before* the OK status +# line -- outside the envelope entirely. `locate', which already went +# through the notice buffer, is pinned next to it. Both are queries: +# neither spends a uuid nor enters the body COMMIT emits. +LOAD "fixtures/midproof.ec" +print b2i. +locate b2i. +QUIET ON +trivial. +trivial. +QUIET OFF +COMMIT diff --git a/tests/llm/scripts/quiet.script b/tests/llm/scripts/quiet.script new file mode 100644 index 000000000..aa2917b6b --- /dev/null +++ b/tests/llm/scripts/quiet.script @@ -0,0 +1,8 @@ +# exit: 0 +# QUIET ON suppresses goal bodies; QUIET OFF restores them. +LOAD "fixtures/simple.ec" 6 +QUIET ON +split. +trivial. +QUIET OFF +GOALS diff --git a/tests/llm/scripts/restart-checkpoint.script b/tests/llm/scripts/restart-checkpoint.script new file mode 100644 index 000000000..82c7faf4f --- /dev/null +++ b/tests/llm/scripts/restart-checkpoint.script @@ -0,0 +1,10 @@ +# exit: 1 +# A checkpoint names a uuid, and `pragma restart.' destroys the uuid +# space it was taken in. The restart must therefore drop the checkpoint +# table too: REVERT may not resolve the name at all afterwards, let +# alone reach a state of the session that no longer exists. +LOAD "fixtures/simple.ec" 6 +split. +CHECKPOINT c +pragma restart. +REVERT c diff --git a/tests/llm/scripts/search-in-proof.script b/tests/llm/scripts/search-in-proof.script new file mode 100644 index 000000000..f462e9cc3 --- /dev/null +++ b/tests/llm/scripts/search-in-proof.script @@ -0,0 +1,10 @@ +# exit: 0 +# A query issued in the middle of a proof must not end up in the body +# COMMIT emits: the two `trivial.` lines are the whole proof. +LOAD "fixtures/midproof.ec" +QUIET ON +trivial. +SEARCH (b2i _) +trivial. +QUIET OFF +COMMIT diff --git a/tests/llm/scripts/search-injection.script b/tests/llm/scripts/search-injection.script new file mode 100644 index 000000000..34ae60310 --- /dev/null +++ b/tests/llm/scripts/search-injection.script @@ -0,0 +1,9 @@ +# exit: 1 +# A SEARCH pattern is a pattern, not EasyCrypt input: a sentence-ending +# '.' inside it must not start a new command. The `split. admit' below +# is rejected outright -- the uuid must not move and COMMIT must stay +# empty. A qualified name (dots and all) is still a legal pattern. +LOAD "fixtures/simple.ec" 6 +SEARCH (_ /\ _). split. admit +COMMIT +SEARCH (RField.ofint _) diff --git a/tests/llm/scripts/search.script b/tests/llm/scripts/search.script new file mode 100644 index 000000000..80a5404ac --- /dev/null +++ b/tests/llm/scripts/search.script @@ -0,0 +1,5 @@ +# exit: 1 +# SEARCH with a pattern, then SEARCH with no argument (error). +LOAD "fixtures/simple.ec" 6 +SEARCH (b2i _) +SEARCH diff --git a/tests/llm/scripts/strict-resume-unstopped.script b/tests/llm/scripts/strict-resume-unstopped.script new file mode 100644 index 000000000..73ad86679 --- /dev/null +++ b/tests/llm/scripts/strict-resume-unstopped.script @@ -0,0 +1,10 @@ +# exit: 1 +# The two ways RESUME refuses, which are the same mistake seen twice: +# a client resuming a session that was never stopped does not know +# where it is, and strict mode exists to say so. First with the mode +# off, then with it on but nothing having failed. +RESUME +STRICT ON +LOAD "fixtures/simple.ec" 5 +RESUME +STRICT OFF diff --git a/tests/llm/scripts/strict-stop.script b/tests/llm/scripts/strict-stop.script new file mode 100644 index 000000000..961fc9357 --- /dev/null +++ b/tests/llm/scripts/strict-stop.script @@ -0,0 +1,23 @@ +# exit: 1 +# Strict mode. Off, a session is a file still being written: a failure +# is reported and the next phrase runs against wherever it left the +# engine. That is the trap a client sending one phrase per call falls +# into, so STRICT ON stops the session at the failure instead. +# +# `apply etrivial.' fails after `split.' has opened two goals, so the +# session is left somewhere the caller did not intend. The `trivial.' +# that follows is refused, and the refusal names the phrase that +# stopped it. GOALS answers all the same -- being stopped is not being +# locked out -- and RESUME releases it. The COMMIT at the end shows the +# body carries no trace of any of it: a failed phrase was never +# recorded, and a refused one never ran. +STRICT ON +LOAD "fixtures/simple.ec" 5 +split. +apply etrivial. +trivial. +GOALS +RESUME +trivial. +trivial. +COMMIT diff --git a/tests/llm/scripts/tree-nested.script b/tests/llm/scripts/tree-nested.script new file mode 100644 index 000000000..ce9dd92a7 --- /dev/null +++ b/tests/llm/scripts/tree-nested.script @@ -0,0 +1,10 @@ +# exit: 0 +# Nested splits: TREE and TREE ALL show dotted labels [1.1.1] ... [2]. +LOAD "fixtures/nested.ec" 6 +QUIET ON +split. +split. +split. +QUIET OFF +TREE +TREE ALL diff --git a/tests/llm/scripts/undo-revert.script b/tests/llm/scripts/undo-revert.script new file mode 100644 index 000000000..34e6800f2 --- /dev/null +++ b/tests/llm/scripts/undo-revert.script @@ -0,0 +1,16 @@ +# exit: 1 +# UNDO, REVERT by numeric uuid, CHECKPOINT + REVERT by name, and the +# CHECKPOINT/REVERT argument errors. +LOAD "fixtures/simple.ec" 6 +CHECKPOINT start +split. +UNDO +split. +trivial. +REVERT 4 +REVERT 3 +CHECKPOINT +REVERT +REVERT start +REVERT nosuch +REVERT 999 diff --git a/tests/llm/scripts/warm-reload.script b/tests/llm/scripts/warm-reload.script new file mode 100644 index 000000000..6958ce75e --- /dev/null +++ b/tests/llm/scripts/warm-reload.script @@ -0,0 +1,13 @@ +# exit: 0 +# The interactive front-ends keep the theories a `require' elaborates +# across the scope rebuild a LOAD does, so that reloading a file does +# not re-read everything under it. Whether that happened is not +# visible on the wire, and must not be: the contract is that a reload +# answers exactly what the first load answered. The two LOAD frames +# below are that contract -- same uuid, same tag, same goal -- and so +# are the two `print' frames, which read the served theory rather than +# the reloaded file. +LOAD "fixtures/sub/reload.ec" 7 +print neighbour. +LOAD "fixtures/sub/reload.ec" 7 +print neighbour. diff --git a/tests/mcp/README.md b/tests/mcp/README.md new file mode 100644 index 000000000..91092d106 --- /dev/null +++ b/tests/mcp/README.md @@ -0,0 +1,307 @@ +# `easycrypt mcp` golden-output tests + +Byte-identity regression harness for the MCP server (`src/ecMcp.ml`). +Each scenario is a newline-delimited script of JSON-RPC messages fed to +`ec.exe mcp` on stdin; the raw protocol stream it writes on stdout, and +its process exit status, are compared against recorded goldens. + +This is the sibling of `../llm`, which does the same for the REPL. The +two front-ends share `src/ecLlmCore.ml`, so most behaviour changes show +up in both sets of goldens — that is the point. + +## Layout + +| Path | Contents | +|------|----------| +| `scripts/*.script` | the JSON-RPC messages piped into the server | +| `expected/*.out` | recorded stdout, one file per script | +| `claude-code.mcp.json` | a ready-to-paste client configuration | +| `../llm/fixtures/*` | the EasyCrypt files the scripts load (shared with the REPL harness, never duplicated) | +| `../../scripts/testing/mcp-golden` | the runner | +| `../../scripts/testing/mcp-parity` | the REPL/MCP parity checker (see below) | +| `../../scripts/testing/mcp-sessions` | the `-sessions` multiplexer checker (see below) | +| `../../scripts/testing/mcp-inspector-check` | manual smoke test against a real client (see below) | + +## Running + +From the repository root: + +``` +make test-mcp # build + run every scenario +scripts/testing/mcp-golden # run every scenario +scripts/testing/mcp-golden happy-path protocol-errors +scripts/testing/mcp-golden --bin /path/to/ec.exe +scripts/testing/mcp-parity -v # the parity check, alone +scripts/testing/mcp-sessions -v # the multiplexer check, alone +``` + +The runner defaults to `_build/default/src/ec.exe`, resolved relative +to the repository root. It prints `PASS`/`FAIL` per scenario, a unified +diff for each mismatch, and exits nonzero if anything failed. That is +the CI invocation. + +## Re-recording + +``` +scripts/testing/mcp-golden --record # all scenarios +scripts/testing/mcp-golden --record tools-list # one scenario +``` + +`--record` overwrites `expected/*.out` with the current binary's +output instead of diffing. It still checks the declared exit status. + +Re-record only deliberately, and read the diff: these goldens are the +gate for changes to the protocol layer. + +## Scenarios + +| Scenario | What it pins | +|----------|--------------| +| `initialize` | the lifecycle handshake, the `initialized` notification, `ping` | +| `version-negotiation` | an unsupported revision falls back to the latest we speak; a supported one is echoed | +| `tools-list` | the whole tool table: names, descriptions, input/output schemas, annotations | +| `happy-path` | a session end to end: load, step, goals, tree, focus, commit | +| `prover-error` | EasyCrypt-level failures as `isError` results carrying the goal state | +| `print-query` | `print` and `locate` reach the agent, spend no uuid, and stay out of `ec_commit` | +| `non-utf8` | engine output that is not UTF-8 comes back as U+FFFD, not as invalid JSON | +| `try-revert` | `ec_try` rolling back a phrase that had already advanced the proof | +| `try-undo` | `ec_try` rolling *forward* again after a phrase whose `undo` lowered the uuid | +| `strict-stop` | `ec_strict` stopping the session at a failure: what is refused, what still answers, and that a failing `ec_try` never stops it | +| `protocol-errors` | `-32700`, `-32600`, `-32601` and the `-32602` family | +| `revert` | `ec_revert` by uuid and by checkpoint name | +| `load-missing` | a missing file and an unknown extension: `isError`, *not* `-32602` | +| `load-options` | `ec_load` with `nosmt`, and with `trace`: the two options that change what the engine does | +| `load-trace-error` | `trace` on a sentence outside a proof: `isError`, and the prefix still in effect | +| `notifications` | notifications, known and unknown, draw no reply | +| `exit` | `exit.` answers "session terminated", then the process stops | +| `eof` | end of input is a clean shutdown, exit 0 | + +## Result shape + +Every `tools/call` result carries the reply text **twice**: + +```json +{"content": [{"type": "text", "text": "Current goal\n..."}], + "structuredContent": {"text": "Current goal\n...", "uuid": 3, + "changed": true}, + "isError": false} +``` + +The two strings are the same by construction — `Result_of.make` takes +one `~text` and writes it into both halves — and `outputSchema` +declares `text` required alongside `uuid` and `changed` (and optional +`reverted`, on `ec_try`). + +The duplication is deliberate, and it is empirical rather than +aesthetic. Claude Code, the client this server is primarily for, hands +the model the `structuredContent` object **alone** and drops `content` +entirely whenever both are present. Isolated against a four-tool probe +server returning the same text under four result shapes (the run is +recorded in the message of commit `e3dce8552`): + +| result shape | payload reaches the model? | +|--------------|----------------------------| +| `content` + `structuredContent`, with `outputSchema` | no | +| `content` + `structuredContent`, without `outputSchema` | no | +| `content` only | yes | +| `content` + `structuredContent` *containing* the text | yes | + +So it is the presence of `structuredContent`, not of `outputSchema`, +that suppresses `content` — and before the text was duplicated, an +agent driving this server through Claude Code saw `{"uuid":3, +"changed":true}` and nothing else, while the Inspector, which displays +both halves, showed no problem at all. + +Row 4 is the shape we ship. Keeping `content` as well as filling +`structuredContent.text` costs one repeated string per reply and keeps +the server correct for spec-abiding clients that read `content`, for +clients that read only the structured half, and for the parity check, +which compares the REPL body against `content[0].text`. + +## Parity + +`make test-mcp` runs `scripts/testing/mcp-parity` after the goldens. +Where the goldens freeze *what* the MCP server answers, the parity +check pins *why the two front-ends can be trusted to agree*: they are +two wire layers over one core, so the same operation must produce the +same answer on both. + +It plays one representative operation per tool family — load, step, +goals, tree, focus, undo, checkpoint, step again, revert, search, +commit, a failing phrase, then strict mode (on, a failure that stops +the session, a refused phrase, resume, off), and finally a `nosmt` +load and a `trace` load (both reset the session, hence last) — in that +order, against two +sessions started from the same directory (`tests/llm`, so both name the fixture +identically and no path difference can leak into a reply): a REPL +session driven with `llm -eval`, and an MCP session driven with a +JSON-RPC script. For each step it asserts two things. + +**The uuid matches.** The REPL's `[uuid:N]` envelope tag against the +MCP result's `structuredContent.uuid`. + +**The payload matches.** The REPL's reply body — everything it prints +between the `OK`/`ERROR` line and `` — against the MCP result's +`content[0].text`, *up to one trailing newline*. That slack is the +whole of the licensed difference: the REPL terminates a body that lacks +a newline so that `` starts a line of its own, and MCP, having no +sentinel, does not. The checker appends that newline and then demands +byte equality. + +The comparison is derived from the two envelopes rather than pattern +matched out of them: the REPL wire is a sequence of blocks opened by a +status line and closed by a lone ``, and the MCP wire is one JSON +object per line. Both are parsed structurally, so the checker cannot +be fooled by a body that happens to contain something envelope-shaped. + +Two asymmetries are structural, and the check deliberately does not +span them: + +* **Envelope tags.** The REPL's `[loaded:file:N]` and `[focus: 1/N]` + annotations ride on the status line, not in the body; MCP's envelope + is `structuredContent`, which carries `text`, `uuid` and `changed`, + none of which reproduces them. So an MCP client does not see them at + all. That is a gap worth closing one day — the natural home is a + further `structuredContent` field — but it is not a parity violation: + no body differs. +* **Notices on failures.** The REPL has never rendered the engine's + notice buffer on an `ERROR` reply; the MCP failure result does + include it. The two therefore agree only when the failing operation + emitted no notices, which is the case for the failing phrase the + check plays. Should a future step want a noisy failure, this is the + invariant to weaken — knowingly, and here. + +## Real clients + +Neither the goldens nor the parity check involve an MCP client: they +speak the wire themselves, so they prove the server is consistent with +itself and with the REPL, not that a client can use it. Two manual +checks close that gap. Neither is in CI — both need network access — +and both should be run after touching `src/ecMcp.ml`. + +**The reference client.** `scripts/testing/mcp-inspector-check` drives +the server with the MCP Inspector's CLI mode, `npx +@modelcontextprotocol/inspector --cli`, over `tools/list` and a +`tools/call` of `ec_load`: + +``` +scripts/testing/mcp-inspector-check --bin ./ec.native +``` + +**Claude Code.** `claude-code.mcp.json` is a project configuration to +drop next to a proof development. It names `easycrypt` on the `PATH`, +so it stays free of absolute paths: + +```json +{"mcpServers": {"easycrypt": {"command": "easycrypt", "args": ["mcp"]}}} +``` + +A project-scoped `.mcp.json` needs interactive approval, so for a +headless check register the server at local scope instead and ask for +its health: + +``` +claude mcp add-json easycrypt '{"command":"/abs/path/ec.exe","args":["mcp"]}' --scope local +claude mcp list # easycrypt: ... - ✔ Connected +claude mcp remove easycrypt -s local +``` + +Health is not the interesting question, though: it was this headless +check that found the payload never reaching the agent (see **Result +shape** above), which no wire-level test can see. So ask the session to +*use* the server and quote back what it got — that, and not the +connection, is what the client-side check is for: + +``` +claude -p 'Call ec_load with file=/tests/llm/fixtures/simple.ec + and line=6, then ec_goals. Quote the goal text verbatim.' \ + --allowedTools mcp__easycrypt__ec_load mcp__easycrypt__ec_goals +``` + +An agent that can quote `1 = 1 /\ 2 = 2` is reading the payload; one +that answers with a bare uuid is not. + +## Expected exit status + +Each `.script` declares its expected process exit status on its first +line: + +``` +# exit: 0 +``` + +Lines starting with `#` are comment lines: the runner strips **all** of +them before piping the script into the server, so they can also be used +for prose. The first `# exit: N` line wins; a script without one fails. + +`easycrypt mcp` exits 0 on end of input and 0 after an `exit.` phrase; +EasyCrypt-level failures are `isError` results, not exit statuses, so +every scenario here declares `# exit: 0`. The field is kept all the +same, so that a future exit path cannot change silently. + +## Determinism rules + +The goldens are compared byte for byte, so scenarios must not leak +anything machine- or environment-dependent: + +* **Relative paths only.** The runner `cd`s into `tests/mcp` before + invoking the binary, and scripts refer to fixtures as + `"../llm/fixtures/simple.ec"`. Error messages echo the path + verbatim, so an absolute one would bake the developer's home + directory into the golden. +* **One fixture is deliberately not UTF-8.** `../llm/fixtures/latin1.ec` + is Latin-1, and the `non-utf8` scenario exists precisely because of + it: a JSON string is UTF-8 by definition, EasyCrypt output is bytes, + and the server repairs the difference (invalid bytes become U+FFFD) + at the single point where a message leaves for the wire. It lives + with the other fixtures under `../llm/fixtures` rather than in a + `tests/mcp/fixtures` of its own, per the rule above: fixtures are + shared, never duplicated. Do not "fix" its encoding — that would make + the scenario vacuous. +* **One normalization, and only one.** `serverInfo.version` is a + git-describe string; the runner rewrites it to `VERSION` with `sed` + before diffing. Nothing else is touched — if a second unstable field + ever appears, that is a bug in the server, not a reason to normalize + more. +* **No SMT.** As in `../llm`: proofs close with `trivial`, `done` or + `split`, never with `smt`, whose availability and timing vary by + machine. +* **stdout only.** stderr carries the engine's diagnostics (the server + points the process's stdout at stderr and keeps a private descriptor + for the protocol); it is discarded. +* Fixtures require `AllCore` only. + +## Reading a golden + +The stream is the protocol: one JSON message per line, unindented, +exactly as a client sees it. `tools-list.out` is therefore a single +very long line, and `diff` will show it whole. To read one by hand: + +``` +python3 -m json.tool < <(head -n 1 tests/mcp/expected/tools-list.out) +``` + +## Adding a scenario + +1. Add `scripts/NAME.script` starting with `# exit: N`. +2. Reuse a fixture from `../llm/fixtures/`; add a new one there (not + here) if none fits. +3. `scripts/testing/mcp-golden --record NAME`. +4. Read `expected/NAME.out` and check it is what you meant to freeze. + +## Sessions + +`make test-mcp` ends with `scripts/testing/mcp-sessions`, which checks +the multiplexer behind `easycrypt mcp -sessions` (`src/ecMcpMux.ml`): +one child `easycrypt mcp` per session name, tool calls forwarded to +the session they name. It is not a golden: the multiplexer answers +concurrently, in whatever order the children finish, and the pids and +idle times of `ec_sessions` are not reproducible. The checker drives +it as a client would, matching replies by id, and asserts the +contract documented in `doc/llm/CLAUDE.md`, "Multi-agent sessions": +the tool table carries `session` on every tool, two sessions load +different files at the same time without seeing each other, the +multiplexer's own errors are tool-level while the engine's protocol +errors pass through, `ec_sessions` and `ec_close` behave, an engine +that exits is reported dead and restarted by the next call, and no +child survives the multiplexer -- on end of input or on SIGTERM. diff --git a/tests/mcp/claude-code.mcp.json b/tests/mcp/claude-code.mcp.json new file mode 100644 index 000000000..4ce195032 --- /dev/null +++ b/tests/mcp/claude-code.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "easycrypt": { + "command": "easycrypt", + "args": ["mcp"] + } + } +} diff --git a/tests/mcp/expected/eof.out b/tests/mcp/expected/eof.out new file mode 100644 index 000000000..8ddc98e50 --- /dev/null +++ b/tests/mcp/expected/eof.out @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} diff --git a/tests/mcp/expected/exit.out b/tests/mcp/expected/exit.out new file mode 100644 index 000000000..366d40ffc --- /dev/null +++ b/tests/mcp/expected/exit.out @@ -0,0 +1,2 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"session terminated"}],"structuredContent":{"text":"session terminated","uuid":0,"changed":false},"isError":false}} diff --git a/tests/mcp/expected/happy-path.out b/tests/mcp/expected/happy-path.out new file mode 100644 index 000000000..23d4391d0 --- /dev/null +++ b/tests/mcp/expected/happy-path.out @@ -0,0 +1,8 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n"}],"structuredContent":{"text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n","uuid":4,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n\n\n\n Goal #2\n ------------------------------------------------------------------------\n 2 = 2\n"}],"structuredContent":{"text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n\n\n\n Goal #2\n ------------------------------------------------------------------------\n 2 = 2\n","uuid":4,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"[1] 1 = 1 <- focused\n[2] 2 = 2\n"}],"structuredContent":{"text":"[1] 1 = 1 <- focused\n[2] 2 = 2\n","uuid":4,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n2 = 2\n"}],"structuredContent":{"text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n2 = 2\n","uuid":5,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"No more goals\n"}],"structuredContent":{"text":"No more goals\n","uuid":7,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":8,"result":{"content":[{"type":"text","text":"split.\n- trivial.\n- trivial.\n"}],"structuredContent":{"text":"split.\n- trivial.\n- trivial.\n","uuid":7,"changed":false},"isError":false}} diff --git a/tests/mcp/expected/initialize.out b/tests/mcp/expected/initialize.out new file mode 100644 index 000000000..1f7c0553c --- /dev/null +++ b/tests/mcp/expected/initialize.out @@ -0,0 +1,2 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":2,"result":{}} diff --git a/tests/mcp/expected/load-missing.out b/tests/mcp/expected/load-missing.out new file mode 100644 index 000000000..d1fb8737e --- /dev/null +++ b/tests/mcp/expected/load-missing.out @@ -0,0 +1,3 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"LOAD: no such file: ../llm/fixtures/nosuchfile.ec"}],"structuredContent":{"text":"LOAD: no such file: ../llm/fixtures/nosuchfile.ec","uuid":0,"changed":false},"isError":true}} +{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"unknown file extension: .txt\nNo active proof.\n"}],"structuredContent":{"text":"unknown file extension: .txt\nNo active proof.\n","uuid":0,"changed":false},"isError":true}} diff --git a/tests/mcp/expected/load-options.out b/tests/mcp/expected/load-options.out new file mode 100644 index 000000000..52b680e81 --- /dev/null +++ b/tests/mcp/expected/load-options.out @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"added lemma: `first_and'\nadded lemma: `second_and'\nadded lemma: `third_and'\nCurrent goal (remaining: 3)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n"}],"structuredContent":{"text":"added lemma: `first_and'\nadded lemma: `second_and'\nadded lemma: `third_and'\nCurrent goal (remaining: 3)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n","uuid":23,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"=== BEFORE: line 8 (col 0) ===\nCurrent goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n\n=== TACTIC (lines 8:0 - 8:6) ===\nsplit.\n\n=== AFTER: line 8 (col 0) ===\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n\n\nType variables: \n\n------------------------------------------------------------------------\n2 = 2\n\n\n=== SUMMARY ===\nopen goals: 1 -> 2\n"}],"structuredContent":{"text":"=== BEFORE: line 8 (col 0) ===\nCurrent goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n\n=== TACTIC (lines 8:0 - 8:6) ===\nsplit.\n\n=== AFTER: line 8 (col 0) ===\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n\n\nType variables: \n\n------------------------------------------------------------------------\n2 = 2\n\n\n=== SUMMARY ===\nopen goals: 1 -> 2\n","uuid":4,"changed":true},"isError":false}} diff --git a/tests/mcp/expected/load-trace-error.out b/tests/mcp/expected/load-trace-error.out new file mode 100644 index 000000000..3579a4103 --- /dev/null +++ b/tests/mcp/expected/load-trace-error.out @@ -0,0 +1,3 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"trace: target sentence is not in a proof context\nNo active proof.\n"}],"structuredContent":{"text":"trace: target sentence is not in a proof context\nNo active proof.\n","uuid":1,"changed":true},"isError":true}} +{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\nb2i true = 1\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\nb2i true = 1\n","uuid":2,"changed":true},"isError":false}} diff --git a/tests/mcp/expected/non-utf8.out b/tests/mcp/expected/non-utf8.out new file mode 100644 index 000000000..f4f2b0a60 --- /dev/null +++ b/tests/mcp/expected/non-utf8.out @@ -0,0 +1,2 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"=== BEFORE: line 10 (col 0) ===\nCurrent goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n\n=== TACTIC (lines 10:0 - 12:8) ===\nby\n(* d�coupe en r�gions *)\ntrivial.\n\n=== AFTER: line 10 (col 0) ===\n(no open goals)\n\n=== SUMMARY ===\nopen goals: 1 -> 0\n"}],"structuredContent":{"text":"=== BEFORE: line 10 (col 0) ===\nCurrent goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n\n=== TACTIC (lines 10:0 - 12:8) ===\nby\n(* d�coupe en r�gions *)\ntrivial.\n\n=== AFTER: line 10 (col 0) ===\n(no open goals)\n\n=== SUMMARY ===\nopen goals: 1 -> 0\n","uuid":4,"changed":true},"isError":false}} diff --git a/tests/mcp/expected/notifications.out b/tests/mcp/expected/notifications.out new file mode 100644 index 000000000..c71fd037e --- /dev/null +++ b/tests/mcp/expected/notifications.out @@ -0,0 +1,2 @@ +{"jsonrpc":"2.0","id":1,"result":{}} +{"jsonrpc":"2.0","id":2,"result":{}} diff --git a/tests/mcp/expected/print-query.out b/tests/mcp/expected/print-query.out new file mode 100644 index 000000000..0f545355d --- /dev/null +++ b/tests/mcp/expected/print-query.out @@ -0,0 +1,6 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n"}],"structuredContent":{"text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n","uuid":4,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"* In [operators, predicates or exceptions]:\n\n(* Int.b2i (shorten name: b2i) *)\nop b2i (b : bool) : int = if b then 1 else 0.\n\nCurrent goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n"}],"structuredContent":{"text":"* In [operators, predicates or exceptions]:\n\n(* Int.b2i (shorten name: b2i) *)\nop b2i (b : bool) : int = if b then 1 else 0.\n\nCurrent goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n","uuid":4,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"In section [operators]\n\n - Int.b2i (shorten name: b2i)\n\nCurrent goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n"}],"structuredContent":{"text":"In section [operators]\n\n - Int.b2i (shorten name: b2i)\n\nCurrent goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n","uuid":4,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"No more goals\n"}],"structuredContent":{"text":"No more goals\n","uuid":6,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":"- trivial.\n- trivial.\n"}],"structuredContent":{"text":"- trivial.\n- trivial.\n","uuid":6,"changed":false},"isError":false}} diff --git a/tests/mcp/expected/protocol-errors.out b/tests/mcp/expected/protocol-errors.out new file mode 100644 index 000000000..d920f69df --- /dev/null +++ b/tests/mcp/expected/protocol-errors.out @@ -0,0 +1,16 @@ +{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"invalid JSON"}} +{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"JSON-RPC batches are not supported by this protocol revision"}} +{"jsonrpc":"2.0","id":2,"error":{"code":-32601,"message":"method not found: server/discover"}} +{"jsonrpc":"2.0","id":3,"error":{"code":-32600,"message":"missing `method'"}} +{"jsonrpc":"2.0","id":4,"error":{"code":-32600,"message":"`method' must be a string"}} +{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"a JSON-RPC message must be an object"}} +{"jsonrpc":"2.0","id":5,"error":{"code":-32602,"message":"unknown tool: ec_nosuchtool"}} +{"jsonrpc":"2.0","id":6,"error":{"code":-32602,"message":"missing tool `name'"}} +{"jsonrpc":"2.0","id":7,"error":{"code":-32602,"message":"`name' must be a string"}} +{"jsonrpc":"2.0","id":8,"error":{"code":-32602,"message":"ec_step: missing required argument `phrase'"}} +{"jsonrpc":"2.0","id":9,"error":{"code":-32602,"message":"ec_goals: `all' must be a boolean"}} +{"jsonrpc":"2.0","id":10,"error":{"code":-32602,"message":"ec_load: `col' requires `line'"}} +{"jsonrpc":"2.0","id":11,"error":{"code":-32602,"message":"ec_load: `line' must be an integer"}} +{"jsonrpc":"2.0","id":12,"error":{"code":-32602,"message":"ec_focus: not a path of integers: 1.oops"}} +{"jsonrpc":"2.0","id":13,"error":{"code":-32602,"message":"ec_focus: path indices must be >= 1: 0"}} +{"jsonrpc":"2.0","id":14,"error":{"code":-32602,"message":"`params' must be an object"}} diff --git a/tests/mcp/expected/prover-error.out b/tests/mcp/expected/prover-error.out new file mode 100644 index 000000000..2cc6d95de --- /dev/null +++ b/tests/mcp/expected/prover-error.out @@ -0,0 +1,6 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":": line 1 (0-18): unknown lemma `nosuchlemma'\nsource: apply nosuchlemma.\nCurrent goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":": line 1 (0-18): unknown lemma `nosuchlemma'\nsource: apply nosuchlemma.\nCurrent goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":false},"isError":true}} +{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"FOCUS: index 7 out of range (1..1)\nCurrent goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":"FOCUS: index 7 out of range (1..1)\nCurrent goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":false},"isError":true}} +{"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"No active proof.\n"}],"structuredContent":{"text":"No active proof.\n","uuid":0,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":"nothing to undo\nNo active proof.\n"}],"structuredContent":{"text":"nothing to undo\nNo active proof.\n","uuid":0,"changed":false},"isError":true}} diff --git a/tests/mcp/expected/revert.out b/tests/mcp/expected/revert.out new file mode 100644 index 000000000..60c85a271 --- /dev/null +++ b/tests/mcp/expected/revert.out @@ -0,0 +1,11 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"checkpoint 'start' set at uuid 3"}],"structuredContent":{"text":"checkpoint 'start' set at uuid 3","uuid":3,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n"}],"structuredContent":{"text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n","uuid":4,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n2 = 2\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n2 = 2\n","uuid":5,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":8,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":9,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":10,"result":{"content":[{"type":"text","text":""}],"structuredContent":{"text":"","uuid":3,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":11,"result":{"content":[{"type":"text","text":"REVERT: 'nosuchname' is not a valid uuid or checkpoint name\nCurrent goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":"REVERT: 'nosuchname' is not a valid uuid or checkpoint name\nCurrent goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":false},"isError":true}} diff --git a/tests/mcp/expected/strict-stop.out b/tests/mcp/expected/strict-stop.out new file mode 100644 index 000000000..47b319a4d --- /dev/null +++ b/tests/mcp/expected/strict-stop.out @@ -0,0 +1,11 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"strict: on -- a failure stops the session until UNDO, REVERT, LOAD or RESUME"}],"structuredContent":{"text":"strict: on -- a failure stops the session until UNDO, REVERT, LOAD or RESUME","uuid":0,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":": line 1 (0-18): unknown lemma `nosuchlemma'\nsource: apply nosuchlemma.\nCurrent goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":": line 1 (0-18): unknown lemma `nosuchlemma'\nsource: apply nosuchlemma.\nCurrent goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":false,"reverted":true},"isError":true}} +{"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":": line 1 (7-25): unknown lemma `nosuchlemma'\nsource: apply nosuchlemma.\nCurrent goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n"}],"structuredContent":{"text":": line 1 (7-25): unknown lemma `nosuchlemma'\nsource: apply nosuchlemma.\nCurrent goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n","uuid":4,"changed":true},"isError":true}} +{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":"strict: the session stopped at a failed phrase and has not been resynchronized\nstopped at: apply nosuchlemma.\nUNDO, REVERT, LOAD or RESUME to continue; GOALS, TREE, SEARCH and COMMIT answer meanwhile\nCurrent goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n"}],"structuredContent":{"text":"strict: the session stopped at a failed phrase and has not been resynchronized\nstopped at: apply nosuchlemma.\nUNDO, REVERT, LOAD or RESUME to continue; GOALS, TREE, SEARCH and COMMIT answer meanwhile\nCurrent goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n","uuid":4,"changed":false},"isError":true}} +{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"strict: the session stopped at a failed phrase and has not been resynchronized\nstopped at: apply nosuchlemma.\nUNDO, REVERT, LOAD or RESUME to continue; GOALS, TREE, SEARCH and COMMIT answer meanwhile\nCurrent goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n"}],"structuredContent":{"text":"strict: the session stopped at a failed phrase and has not been resynchronized\nstopped at: apply nosuchlemma.\nUNDO, REVERT, LOAD or RESUME to continue; GOALS, TREE, SEARCH and COMMIT answer meanwhile\nCurrent goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n","uuid":4,"changed":false,"reverted":true},"isError":true}} +{"jsonrpc":"2.0","id":8,"result":{"content":[{"type":"text","text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n"}],"structuredContent":{"text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n","uuid":4,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":9,"result":{"content":[{"type":"text","text":"split.\n"}],"structuredContent":{"text":"split.\n","uuid":4,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":10,"result":{"content":[{"type":"text","text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n"}],"structuredContent":{"text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n","uuid":4,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":11,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n2 = 2\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n2 = 2\n","uuid":5,"changed":true},"isError":false}} diff --git a/tests/mcp/expected/tools-list.out b/tests/mcp/expected/tools-list.out new file mode 100644 index 000000000..6464e5836 --- /dev/null +++ b/tests/mcp/expected/tools-list.out @@ -0,0 +1 @@ +{"jsonrpc":"2.0","id":1,"result":{"tools":[{"name":"ec_load","description":"Reset the session and compile FILE from the top, stopping after the last sentence that ends on or before LINE (and column COL when given). This is the entry point: every other tool needs a loaded file, and tactics need the position to land inside a proof. Set nosmt to weaken SMT calls while replaying a prefix that was already verified, which is much faster on large files. Set noproof to go further and skip the prefix's proofs altogether, admitting every lemma before the target on its statement alone -- only the proof the position lands inside is replayed, which is the fastest way into a proof in a long file. Set trace to have the reply describe the last loaded sentence as BEFORE / TACTIC / AFTER / SUMMARY blocks. The reply reports where compilation stopped and the resulting goal state; note the uuid it returns, reverting to it is the instant way back to the start of the proof.","inputSchema":{"type":"object","properties":{"file":{"type":"string","description":"path to the .ec/.eca file"},"line":{"type":"integer","description":"stop after the last sentence ending on or before this line; omit to compile the whole file"},"col":{"type":"integer","description":"column bound within `line'; requires `line'"},"nosmt":{"type":"boolean","description":"weaken SMT calls while compiling the prefix","default":false},"noproof":{"type":"boolean","description":"skip the prefix's proofs entirely, admitting the lemmas before the target as axioms","default":false},"trace":{"type":"boolean","description":"report the proof state around the last loaded sentence","default":false}},"required":["file"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"text":{"type":"string","description":"the reply body -- goal state, proof body, search results, error text; the same string as content[0].text"},"uuid":{"type":"integer","description":"engine state identifier after the call; pass it to ec_revert to come back here"},"changed":{"type":"boolean","description":"whether the engine state advanced"}},"required":["text","uuid","changed"]},"annotations":{"destructiveHint":true}},{"name":"ec_step","description":"Run EasyCrypt sentences -- tactics, declarations, require, print, ... -- against the current session. Every complete sentence in the argument is executed, in order, exactly as if the text had been appended to the source file, and a single reply describes the state they leave behind; sentences may span several lines. Requires a file loaded with ec_load, and, for tactics, an open proof. On success the reply carries the new goal state; on failure the prover's error text comes back with isError set, the sentences before the failing one stay applied and the engine is left wherever that sentence left it -- use ec_try when you want a guaranteed rollback. Successful non-query phrases are recorded for ec_commit.","inputSchema":{"type":"object","properties":{"phrase":{"type":"string","description":"one or more complete EasyCrypt sentences, each ending with `.'"}},"required":["phrase"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"text":{"type":"string","description":"the reply body -- goal state, proof body, search results, error text; the same string as content[0].text"},"uuid":{"type":"integer","description":"engine state identifier after the call; pass it to ec_revert to come back here"},"changed":{"type":"boolean","description":"whether the engine state advanced"}},"required":["text","uuid","changed"]},"annotations":{"destructiveHint":false,"idempotentHint":false}},{"name":"ec_try","description":"Like ec_step, but the engine is rolled back to the state it had before the call whenever a sentence fails, including input that failed only after having already advanced the proof. The failure reply sets structuredContent.reverted to true, and its uuid and goal text describe the restored state, not the point of failure. Use this to probe a tactic without having to ec_revert afterwards; use ec_step when you mean to keep whatever progress the phrase makes. A successful phrase behaves exactly as under ec_step and is recorded for ec_commit.","inputSchema":{"type":"object","properties":{"phrase":{"type":"string","description":"one complete EasyCrypt sentence, ending with `.'"}},"required":["phrase"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"text":{"type":"string","description":"the reply body -- goal state, proof body, search results, error text; the same string as content[0].text"},"uuid":{"type":"integer","description":"engine state identifier after the call; pass it to ec_revert to come back here"},"changed":{"type":"boolean","description":"whether the engine state advanced"},"reverted":{"type":"boolean","description":"set when the phrase failed and the engine was rolled back to its pre-call state"}},"required":["text","uuid","changed"]},"annotations":{"destructiveHint":false}},{"name":"ec_goals","description":"Print the current proof state: the focused subgoal alone, or, with all set, every open subgoal. Requires an open proof, and does not advance the engine.","inputSchema":{"type":"object","properties":{"all":{"type":"boolean","description":"print every open subgoal instead of the focused one","default":false}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"text":{"type":"string","description":"the reply body -- goal state, proof body, search results, error text; the same string as content[0].text"},"uuid":{"type":"integer","description":"engine state identifier after the call; pass it to ec_revert to come back here"},"changed":{"type":"boolean","description":"whether the engine state advanced"}},"required":["text","uuid","changed"]},"annotations":{"readOnlyHint":true}},{"name":"ec_tree","description":"List the open subgoals as a tree of dotted-path labels -- [1], [1.2], [2.1.1] -- showing how the splits nest, and marking the focused one. Those labels are exactly what ec_focus accepts. Set full for whole goal bodies rather than one-line conclusions. The labels are not stable across focus changes: the tree always shows the focused goal first, so re-read it after every ec_focus. Does not advance the engine.","inputSchema":{"type":"object","properties":{"full":{"type":"boolean","description":"print full goal bodies instead of one-line conclusions","default":false}},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"text":{"type":"string","description":"the reply body -- goal state, proof body, search results, error text; the same string as content[0].text"},"uuid":{"type":"integer","description":"engine state identifier after the call; pass it to ec_revert to come back here"},"changed":{"type":"boolean","description":"whether the engine state advanced"}},"required":["text","uuid","changed"]},"annotations":{"readOnlyHint":true}},{"name":"ec_focus","description":"Rotate the focus onto the subgoal at dotted path PATH, as printed by ec_tree (\"2\", \"1.2\", \"1.1.1\"). The path walks the tree, one component per level, so a single integer selects the k-th TOP-LEVEL node -- not the k-th open goal: with four goals nested under two top-level nodes, \"3\" is out of range. Selecting a node that is an internal frame rather than a leaf goal is an error. The special value \"next\" is a different operation, not a synonym for \"2\": it moves to the next open subgoal in ec_goals-with-all order, whatever the nesting, and the two coincide only when the tree is flat. Subsequent tactics act on the focused goal.","inputSchema":{"type":"object","properties":{"path":{"type":"string","description":"\"N\", a dotted path \"N1.N2...\", or \"next\""}},"required":["path"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"text":{"type":"string","description":"the reply body -- goal state, proof body, search results, error text; the same string as content[0].text"},"uuid":{"type":"integer","description":"engine state identifier after the call; pass it to ec_revert to come back here"},"changed":{"type":"boolean","description":"whether the engine state advanced"}},"required":["text","uuid","changed"]},"annotations":{"destructiveHint":false}},{"name":"ec_undo","description":"Undo the last engine step, returning to the immediately preceding state. The ec_commit transcript is trimmed to match. Fails when there is nothing left to undo.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"text":{"type":"string","description":"the reply body -- goal state, proof body, search results, error text; the same string as content[0].text"},"uuid":{"type":"integer","description":"engine state identifier after the call; pass it to ec_revert to come back here"},"changed":{"type":"boolean","description":"whether the engine state advanced"}},"required":["text","uuid","changed"]},"annotations":{"destructiveHint":false}},{"name":"ec_revert","description":"Return the session to an earlier state, named either by a uuid reported in some previous structuredContent or by a name given to ec_checkpoint. Reverting is instant, unlike re-running ec_load, so going back to the uuid ec_load returned is the cheap way to restart a proof from scratch after a failed experiment. The ec_commit transcript is trimmed to match.","inputSchema":{"type":"object","properties":{"target":{"type":"string","description":"a uuid (as a decimal string) or a checkpoint name"}},"required":["target"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"text":{"type":"string","description":"the reply body -- goal state, proof body, search results, error text; the same string as content[0].text"},"uuid":{"type":"integer","description":"engine state identifier after the call; pass it to ec_revert to come back here"},"changed":{"type":"boolean","description":"whether the engine state advanced"}},"required":["text","uuid","changed"]},"annotations":{"destructiveHint":true}},{"name":"ec_checkpoint","description":"Record the current uuid under NAME, so that ec_revert can address it by name later. Worth doing before a branching experiment, when carrying the bare uuid around is awkward. Does not change the proof state.","inputSchema":{"type":"object","properties":{"name":{"type":"string","description":"checkpoint name"}},"required":["name"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"text":{"type":"string","description":"the reply body -- goal state, proof body, search results, error text; the same string as content[0].text"},"uuid":{"type":"integer","description":"engine state identifier after the call; pass it to ec_revert to come back here"},"changed":{"type":"boolean","description":"whether the engine state advanced"}},"required":["text","uuid","changed"]},"annotations":{"readOnlyHint":true}},{"name":"ec_commit","description":"Emit the phrases recorded since the last ec_load as a proof body, with bullets inserted at every multi-child split: the result compiles under `pragma +strict_bullets' and can be pasted straight into the source file. Queries (search, print, locate, ec_search) are never recorded, so looking things up mid-proof does not pollute the body, and ec_undo / ec_revert trim the transcript. Still works after `qed.'. Does not change the proof state.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"text":{"type":"string","description":"the reply body -- goal state, proof body, search results, error text; the same string as content[0].text"},"uuid":{"type":"integer","description":"engine state identifier after the call; pass it to ec_revert to come back here"},"changed":{"type":"boolean","description":"whether the engine state advanced"}},"required":["text","uuid","changed"]},"annotations":{"readOnlyHint":true}},{"name":"ec_strict","description":"Turn strict mode on or off. Off (the default) a session behaves as a source file does: a failing phrase is reported and the next call runs against wherever it left the engine. On, the session stops at a failure that may have moved the engine, and ec_step, ec_try and ec_focus are refused until it is resynchronized -- by ec_undo, ec_revert or ec_load, which arrive somewhere definite, or by ec_resume, which says so. Turn it on if you send one phrase per call and act on each result: without it a failure is followed by calls landing on a state you did not mean, and the drift is silent. Reads (ec_goals, ec_tree, ec_search, ec_checkpoint, ec_commit) always answer, stopped or not.","inputSchema":{"type":"object","properties":{"on":{"type":"boolean","description":"true to stop the session at a failure"}},"required":["on"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"text":{"type":"string","description":"the reply body -- goal state, proof body, search results, error text; the same string as content[0].text"},"uuid":{"type":"integer","description":"engine state identifier after the call; pass it to ec_revert to come back here"},"changed":{"type":"boolean","description":"whether the engine state advanced"}},"required":["text","uuid","changed"]},"annotations":{"destructiveHint":false}},{"name":"ec_resume","description":"Release a strict-mode stop without moving the engine: you have read the failure and mean to carry on from where it left the session. Use ec_undo or ec_revert instead when you would rather go back. Fails when the session is not stopped, or when strict mode is off -- either way you are not where you think you are, which is what strict mode is for.","inputSchema":{"type":"object","properties":{},"additionalProperties":false},"outputSchema":{"type":"object","properties":{"text":{"type":"string","description":"the reply body -- goal state, proof body, search results, error text; the same string as content[0].text"},"uuid":{"type":"integer","description":"engine state identifier after the call; pass it to ec_revert to come back here"},"changed":{"type":"boolean","description":"whether the engine state advanced"}},"required":["text","uuid","changed"]},"annotations":{"destructiveHint":false}},{"name":"ec_search","description":"Search the environment for lemmas matching an EasyCrypt search pattern. This is pattern syntax, not keyword search: use _ as the wildcard, as in \"(fdom _)\", \"(_ %/ _)\" or \"(mu _ _) (_ <= _)\". Requires a loaded file. The query neither advances the proof nor enters the ec_commit transcript.","inputSchema":{"type":"object","properties":{"pattern":{"type":"string","description":"an EasyCrypt search pattern"}},"required":["pattern"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"text":{"type":"string","description":"the reply body -- goal state, proof body, search results, error text; the same string as content[0].text"},"uuid":{"type":"integer","description":"engine state identifier after the call; pass it to ec_revert to come back here"},"changed":{"type":"boolean","description":"whether the engine state advanced"}},"required":["text","uuid","changed"]},"annotations":{"readOnlyHint":true}}]}} diff --git a/tests/mcp/expected/try-revert.out b/tests/mcp/expected/try-revert.out new file mode 100644 index 000000000..665049196 --- /dev/null +++ b/tests/mcp/expected/try-revert.out @@ -0,0 +1,7 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":": line 1 (7-25): unknown lemma `nosuchlemma'\nsource: apply nosuchlemma.\nCurrent goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":": line 1 (7-25): unknown lemma `nosuchlemma'\nsource: apply nosuchlemma.\nCurrent goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":false,"reverted":true},"isError":true}} +{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":""}],"structuredContent":{"text":"","uuid":3,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n"}],"structuredContent":{"text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n","uuid":4,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":7,"result":{"content":[{"type":"text","text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n"}],"structuredContent":{"text":"Current goal (remaining: 2)\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1\n","uuid":4,"changed":false},"isError":false}} diff --git a/tests/mcp/expected/try-undo.out b/tests/mcp/expected/try-undo.out new file mode 100644 index 000000000..94cf99fbd --- /dev/null +++ b/tests/mcp/expected/try-undo.out @@ -0,0 +1,6 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n"}],"structuredContent":{"text":"Current goal\n\nType variables: \n\n------------------------------------------------------------------------\n1 = 1 /\\ 2 = 2\n","uuid":3,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"No more goals\n"}],"structuredContent":{"text":"No more goals\n","uuid":6,"changed":true},"isError":false}} +{"jsonrpc":"2.0","id":4,"result":{"content":[{"type":"text","text":": line 1 (8-26): unknown lemma `nosuchlemma'\nsource: apply nosuchlemma.\nNo more goals\n"}],"structuredContent":{"text":": line 1 (8-26): unknown lemma `nosuchlemma'\nsource: apply nosuchlemma.\nNo more goals\n","uuid":6,"changed":false,"reverted":true},"isError":true}} +{"jsonrpc":"2.0","id":5,"result":{"content":[{"type":"text","text":"No more goals\n"}],"structuredContent":{"text":"No more goals\n","uuid":6,"changed":false},"isError":false}} +{"jsonrpc":"2.0","id":6,"result":{"content":[{"type":"text","text":"split.\n- trivial.\n- trivial.\n"}],"structuredContent":{"text":"split.\n- trivial.\n- trivial.\n","uuid":6,"changed":false},"isError":false}} diff --git a/tests/mcp/expected/version-negotiation.out b/tests/mcp/expected/version-negotiation.out new file mode 100644 index 000000000..6e0aa6aa9 --- /dev/null +++ b/tests/mcp/expected/version-negotiation.out @@ -0,0 +1,3 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":2,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} +{"jsonrpc":"2.0","id":3,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"easycrypt","version":"VERSION"}}} diff --git a/tests/mcp/scripts/eof.script b/tests/mcp/scripts/eof.script new file mode 100644 index 000000000..b50d488d2 --- /dev/null +++ b/tests/mcp/scripts/eof.script @@ -0,0 +1,3 @@ +# exit: 0 +# End of input is a clean shutdown, exit 0, with no farewell message. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}} diff --git a/tests/mcp/scripts/exit.script b/tests/mcp/scripts/exit.script new file mode 100644 index 000000000..cbec912ad --- /dev/null +++ b/tests/mcp/scripts/exit.script @@ -0,0 +1,7 @@ +# exit: 0 +# `exit.' ends the session: the response still goes out, then the +# process stops. The ping after it is never read, so it must not +# appear in the golden. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ec_step","arguments":{"phrase":"exit."}}} +{"jsonrpc":"2.0","id":3,"method":"ping"} diff --git a/tests/mcp/scripts/happy-path.script b/tests/mcp/scripts/happy-path.script new file mode 100644 index 000000000..c5961b668 --- /dev/null +++ b/tests/mcp/scripts/happy-path.script @@ -0,0 +1,13 @@ +# exit: 0 +# A whole session over MCP: load a file up to its `proof.', split, +# inspect the goals and the tree, close both branches, and read the +# proof body back out of ec_commit. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/simple.ec","line":6}}} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ec_step","arguments":{"phrase":"split."}}} +{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"ec_goals","arguments":{"all":true}}} +{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ec_tree","arguments":{}}} +{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ec_focus","arguments":{"path":"2"}}} +{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"ec_step","arguments":{"phrase":"trivial. trivial."}}} +{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"ec_commit","arguments":{}}} diff --git a/tests/mcp/scripts/initialize.script b/tests/mcp/scripts/initialize.script new file mode 100644 index 000000000..ea3ac3291 --- /dev/null +++ b/tests/mcp/scripts/initialize.script @@ -0,0 +1,6 @@ +# exit: 0 +# The lifecycle handshake: initialize, the client's initialized +# notification (no reply), then a ping. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"golden","version":"0"}}} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":2,"method":"ping"} diff --git a/tests/mcp/scripts/load-missing.script b/tests/mcp/scripts/load-missing.script new file mode 100644 index 000000000..e7aa4ef5a --- /dev/null +++ b/tests/mcp/scripts/load-missing.script @@ -0,0 +1,8 @@ +# exit: 0 +# A file the tool cannot find is an EasyCrypt-level failure, so it +# comes back as an isError result and NOT as a -32602: the arguments +# satisfy the schema, it is the world that does not. Same for a file +# whose extension the loader does not know. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/nosuchfile.ec"}}} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/notec.txt"}}} diff --git a/tests/mcp/scripts/load-options.script b/tests/mcp/scripts/load-options.script new file mode 100644 index 000000000..e4c7ce178 --- /dev/null +++ b/tests/mcp/scripts/load-options.script @@ -0,0 +1,14 @@ +# exit: 0 +# The three ec_load options that change what the engine does, which the +# other scenarios never set. `nosmt' weakens SMT calls while replaying +# the prefix; `noproof' skips the prefix's proofs whole, admitting the +# lemmas before the target and replaying only the proof the position +# lands inside; `trace' has the reply describe the last loaded sentence +# as BEFORE/TACTIC/AFTER/SUMMARY instead of just showing the goals. +# The REPL side of the three is tests/llm's load-nosmt, load-noproof +# and load-trace. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/simple.ec","line":6,"nosmt":true}}} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/noproof.ec","line":32,"noproof":true}}} +{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/midproof.ec","trace":true}}} diff --git a/tests/mcp/scripts/load-trace-error.script b/tests/mcp/scripts/load-trace-error.script new file mode 100644 index 000000000..47ae5f48a --- /dev/null +++ b/tests/mcp/scripts/load-trace-error.script @@ -0,0 +1,11 @@ +# exit: 0 +# `trace' whose target sentence is outside any proof: tracing fails, so +# the call is an isError result -- but the prefix must be in effect +# exactly as after a plain ec_load, the deferred sentence included. The +# ec_step below resolves `b2i', which only the traced-and-failed +# `require import AllCore.' can have brought in. Mirrors tests/llm's +# load-trace-notinproof. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/simple.ec","line":3,"trace":true}}} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ec_step","arguments":{"phrase":"lemma preserved : b2i true = 1."}}} diff --git a/tests/mcp/scripts/non-utf8.script b/tests/mcp/scripts/non-utf8.script new file mode 100644 index 000000000..521b5a66a --- /dev/null +++ b/tests/mcp/scripts/non-utf8.script @@ -0,0 +1,9 @@ +# exit: 0 +# A JSON string is UTF-8; EasyCrypt output is bytes. ../llm/fixtures/ +# latin1.ec hides a Latin-1 comment inside the sentence ec_load traces, +# so the reply text carries raw 0xe9 bytes. They must reach the wire as +# U+FFFD: before the repair, this response line was not parseable JSON +# at all. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/latin1.ec","line":12,"trace":true}}} diff --git a/tests/mcp/scripts/notifications.script b/tests/mcp/scripts/notifications.script new file mode 100644 index 000000000..9eaadab7e --- /dev/null +++ b/tests/mcp/scripts/notifications.script @@ -0,0 +1,13 @@ +# exit: 0 +# Notifications never draw a reply, whatever they are: the three the +# spec has us tolerate, an unknown one, and a message whose id is +# null (which MCP forbids, so we read it as "no id" and stay silent). +# The two pings bracket them, so the golden shows nothing in between. +{"jsonrpc":"2.0","id":1,"method":"ping"} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}} +{"jsonrpc":"2.0","method":"notifications/roots/list_changed"} +{"jsonrpc":"2.0","method":"notifications/nobody/knows"} +{"jsonrpc":"2.0","id":null,"method":"ping"} + +{"jsonrpc":"2.0","id":2,"method":"ping"} diff --git a/tests/mcp/scripts/print-query.script b/tests/mcp/scripts/print-query.script new file mode 100644 index 000000000..4b3f3208d --- /dev/null +++ b/tests/mcp/scripts/print-query.script @@ -0,0 +1,13 @@ +# exit: 0 +# `print' reaches the agent. Its output used to go to the process's +# stdout, which this server points at stderr, so an ec_step of +# `print b2i.' came back with an empty body. `locate', which already +# went through the notice buffer, is pinned next to it. Neither is +# recorded for ec_commit. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/midproof.ec"}}} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ec_step","arguments":{"phrase":"print b2i."}}} +{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"ec_step","arguments":{"phrase":"locate b2i."}}} +{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ec_step","arguments":{"phrase":"trivial. trivial."}}} +{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ec_commit","arguments":{}}} diff --git a/tests/mcp/scripts/protocol-errors.script b/tests/mcp/scripts/protocol-errors.script new file mode 100644 index 000000000..4d18d3ba6 --- /dev/null +++ b/tests/mcp/scripts/protocol-errors.script @@ -0,0 +1,22 @@ +# exit: 0 +# Protocol-level failures, which are JSON-RPC errors and never +# isError results: malformed JSON (-32700), a batch array and a +# malformed envelope (-32600), an unknown method (-32601), and the +# -32602 family -- unknown tool, missing and ill-typed arguments, +# `col' without `line', and an unparsable ec_focus path. +{not json at all +[{"jsonrpc":"2.0","id":1,"method":"ping"}] +{"jsonrpc":"2.0","id":2,"method":"server/discover"} +{"jsonrpc":"2.0","id":3} +{"jsonrpc":"2.0","id":4,"method":42} +"just a string" +{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ec_nosuchtool","arguments":{}}} +{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"arguments":{}}} +{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":17,"arguments":{}}} +{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"ec_step","arguments":{}}} +{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"ec_goals","arguments":{"all":"yes"}}} +{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/simple.ec","col":3}}} +{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/simple.ec","line":"6"}}} +{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"ec_focus","arguments":{"path":"1.oops"}}} +{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"ec_focus","arguments":{"path":"0"}}} +{"jsonrpc":"2.0","id":14,"method":"tools/call","params":"not an object"} diff --git a/tests/mcp/scripts/prover-error.script b/tests/mcp/scripts/prover-error.script new file mode 100644 index 000000000..a599dfea9 --- /dev/null +++ b/tests/mcp/scripts/prover-error.script @@ -0,0 +1,11 @@ +# exit: 0 +# An EasyCrypt-level failure is data, not a protocol error: a +# successful response carrying isError, the prover's message and the +# goal state at the point of failure. ec_undo then reports there is +# nothing left to undo, which is the same kind of failure. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/simple.ec","line":6}}} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ec_step","arguments":{"phrase":"apply nosuchlemma."}}} +{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"ec_focus","arguments":{"path":"7"}}} +{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ec_revert","arguments":{"target":"0"}}} +{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ec_undo","arguments":{}}} diff --git a/tests/mcp/scripts/revert.script b/tests/mcp/scripts/revert.script new file mode 100644 index 000000000..2f37151c0 --- /dev/null +++ b/tests/mcp/scripts/revert.script @@ -0,0 +1,15 @@ +# exit: 0 +# ec_revert addressed both ways: by the uuid ec_load reported, and by +# a name given to ec_checkpoint. Each revert is followed by ec_goals, +# so the golden records where the session actually landed. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/simple.ec","line":6}}} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ec_checkpoint","arguments":{"name":"start"}}} +{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"ec_step","arguments":{"phrase":"split."}}} +{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ec_revert","arguments":{"target":"3"}}} +{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ec_goals","arguments":{}}} +{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"ec_step","arguments":{"phrase":"split. trivial."}}} +{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"ec_revert","arguments":{"target":"start"}}} +{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"ec_goals","arguments":{}}} +{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"ec_commit","arguments":{}}} +{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"ec_revert","arguments":{"target":"nosuchname"}}} diff --git a/tests/mcp/scripts/strict-stop.script b/tests/mcp/scripts/strict-stop.script new file mode 100644 index 000000000..69bf6fa45 --- /dev/null +++ b/tests/mcp/scripts/strict-stop.script @@ -0,0 +1,21 @@ +# exit: 0 +# Strict mode over MCP, and the one case the REPL cannot show: ec_try +# is refused while stopped like anything else that would advance, +# although a failing ec_try never arms the stop in the first place -- +# its contract is that a failure moves nothing, so there is no drift +# to prevent. +# +# `apply nosuchlemma.' fails after `split.' has moved the engine, so +# the session stops. ec_step and ec_try are then refused with isError; +# ec_goals and ec_commit answer; ec_resume releases it. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ec_strict","arguments":{"on":true}}} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/simple.ec","line":6}}} +{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"ec_try","arguments":{"phrase":"apply nosuchlemma."}}} +{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ec_step","arguments":{"phrase":"split. apply nosuchlemma."}}} +{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ec_step","arguments":{"phrase":"trivial."}}} +{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"ec_try","arguments":{"phrase":"trivial."}}} +{"jsonrpc":"2.0","id":8,"method":"tools/call","params":{"name":"ec_goals","arguments":{}}} +{"jsonrpc":"2.0","id":9,"method":"tools/call","params":{"name":"ec_commit","arguments":{}}} +{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"ec_resume","arguments":{}}} +{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"ec_step","arguments":{"phrase":"trivial."}}} diff --git a/tests/mcp/scripts/tools-list.script b/tests/mcp/scripts/tools-list.script new file mode 100644 index 000000000..ea2b8ad25 --- /dev/null +++ b/tests/mcp/scripts/tools-list.script @@ -0,0 +1,5 @@ +# exit: 0 +# The tool declarations, verbatim. This golden pins the agent-facing +# contract: names, descriptions, input schemas, output schemas and +# annotations. Editing any tool description re-records this file. +{"jsonrpc":"2.0","id":1,"method":"tools/list"} diff --git a/tests/mcp/scripts/try-revert.script b/tests/mcp/scripts/try-revert.script new file mode 100644 index 000000000..2d4e7e3f4 --- /dev/null +++ b/tests/mcp/scripts/try-revert.script @@ -0,0 +1,12 @@ +# exit: 0 +# ec_try rolls back. The phrase advances the proof (`split.') before +# failing, so the rollback has real work to do: the failure reply +# reports reverted true and changed false, and the ec_goals that +# follows proves the pre-call goal is back. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/simple.ec","line":6}}} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ec_try","arguments":{"phrase":"split. apply nosuchlemma."}}} +{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"ec_goals","arguments":{}}} +{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ec_commit","arguments":{}}} +{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ec_try","arguments":{"phrase":"split."}}} +{"jsonrpc":"2.0","id":7,"method":"tools/call","params":{"name":"ec_goals","arguments":{}}} diff --git a/tests/mcp/scripts/try-undo.script b/tests/mcp/scripts/try-undo.script new file mode 100644 index 000000000..d4fc598c8 --- /dev/null +++ b/tests/mcp/scripts/try-undo.script @@ -0,0 +1,14 @@ +# exit: 0 +# ec_try rolls back input that moved the engine *down*. The proof is +# closed at uuid 6; the phrase then runs `undo 3.' before failing, so +# the rollback has to move forward again -- which the old `undo pre' +# could not do, leaving the session three states back while reporting +# reverted true. The ec_goals and ec_commit that follow prove the +# closed proof and its transcript are both back. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{}}} +{"jsonrpc":"2.0","method":"notifications/initialized"} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"ec_load","arguments":{"file":"../llm/fixtures/simple.ec","line":6}}} +{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"ec_step","arguments":{"phrase":"split. trivial. trivial."}}} +{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"ec_try","arguments":{"phrase":"undo 3. apply nosuchlemma."}}} +{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"ec_goals","arguments":{}}} +{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"ec_commit","arguments":{}}} diff --git a/tests/mcp/scripts/version-negotiation.script b/tests/mcp/scripts/version-negotiation.script new file mode 100644 index 000000000..f638af715 --- /dev/null +++ b/tests/mcp/scripts/version-negotiation.script @@ -0,0 +1,7 @@ +# exit: 0 +# Version negotiation. An unsupported revision gets the latest one we +# speak; a supported older revision is echoed back; a missing or +# non-string protocolVersion also falls back to the latest. +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2099-01-01","capabilities":{},"clientInfo":{"name":"golden","version":"0"}}} +{"jsonrpc":"2.0","id":2,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{}}} +{"jsonrpc":"2.0","id":3,"method":"initialize","params":{"capabilities":{}}}