From d466ff5e6e7bb02da4bc69f64ed3ad71e9307cd4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 30 Sep 2026 05:13:32 +0000 Subject: [PATCH] HowlFrame #90 Phase 3b: experimental while bytecode -compile-hfir-bc now emits existing JUMP_IF_FALSE and JUMP for while. A while header's control edges are the condition, then the body. Production -compile-bc stays AST bytecode after the gate. #90 stays Partial. Co-authored-by: William Elias --- change_log.md | 3 +- docs/hfir_execution_status.md | 13 +- docs/hfir_failure_localization_status.md | 14 +- ...26-09-30_lowered_hfir_abi_phase3b_while.md | 38 ++++ docs/reference/lowered_hfir_abi_v1.md | 22 +- howlframe.go | 6 +- howlframe_test.go | 40 +++- improvements.md | 8 +- internal/hfir/abi_v1_test.go | 48 +++-- internal/hfir/bytecode.go | 45 +++- internal/hfir/defun_call_test.go | 8 +- internal/hfir/localization.go | 10 + internal/hfir/lowering.go | 25 ++- internal/hfir/model_adapter.go | 1 + internal/hfir/verifier.go | 3 + internal/hfir/while_test.go | 193 ++++++++++++++++++ internal/vm/hfir_equivalence_test.go | 68 ++++++ tests/conformance/abi_v1/10_while.howl | 7 + tests/conformance/lowered_hfir_abi_v1.json | 10 + 19 files changed, 503 insertions(+), 59 deletions(-) create mode 100644 docs/journals/2026-09-30_lowered_hfir_abi_phase3b_while.md create mode 100644 internal/hfir/while_test.go create mode 100644 tests/conformance/abi_v1/10_while.howl diff --git a/change_log.md b/change_log.md index c1696a2..e62554c 100644 --- a/change_log.md +++ b/change_log.md @@ -3,7 +3,8 @@ ## Unreleased ### Changed -* Experimental `-compile-hfir-bc` compiles and runs `defun`, `call`, and `return` with the existing `CALL` and `RETURN` opcodes. `while` is still rejected on that path. Production `-compile-bc` is still AST bytecode after the gate. #90 stays Partial. Journal: `docs/journals/2026-09-30_lowered_hfir_abi_phase3a_defun_call.md`. +* Experimental `-compile-hfir-bc` compiles and runs `while` with the existing `JUMP_IF_FALSE` and `JUMP` opcodes. A while header's control edges are the condition and then the body. Production `-compile-bc` is still AST bytecode after the gate. #90 stays Partial. Journal: `docs/journals/2026-09-30_lowered_hfir_abi_phase3b_while.md`. +* Experimental `-compile-hfir-bc` compiles and runs `defun`, `call`, and `return` with the existing `CALL` and `RETURN` opcodes. Production `-compile-bc` is still AST bytecode after the gate. #90 stays Partial. Journal: `docs/journals/2026-09-30_lowered_hfir_abi_phase3a_defun_call.md`. * Generated Go and JavaScript mediate `(fetch)`. An empty, missing, or non-`network` `HOWLFRAME_ALLOW_CAPS` grant fails with `CAPABILITY_DENIED` before any HTTP request, and the denial does not include the URL. The `network` grant performs the request. The production `-compile-bc` path is unchanged. Journal: `docs/journals/2026-09-30_lowered_hfir_abi_phase2d_fetch.md`. * Generated Go and JavaScript mediate `(read_file)`. An empty, missing, or non-`filesystem` `HOWLFRAME_ALLOW_CAPS` grant fails with `CAPABILITY_DENIED` before any filesystem read, and the denial does not include the path. The `filesystem` grant reads the file. The production `-compile-bc` path is unchanged. Journal: `docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md`. * Generated Go and JavaScript mediate `(exec)`. An empty, missing, or non-`process` `HOWLFRAME_ALLOW_CAPS` grant fails with `CAPABILITY_DENIED` before a subprocess starts, and the denial does not include the command. The `process` grant runs it. The production `-compile-bc` path is unchanged. Journal: `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`. diff --git a/docs/hfir_execution_status.md b/docs/hfir_execution_status.md index da0cb00..9058f18 100644 --- a/docs/hfir_execution_status.md +++ b/docs/hfir_execution_status.md @@ -25,7 +25,7 @@ Semantic information added for this path includes literal kind, explicit program ## What AST still owns -The parser, source expansion, module resolution, patch/context transformations, checker rules, construct-position classification, and public build integration still operate on the AST. The legacy AST bytecode compiler remains the production compiler. Phase 3a teaches the experimental lowerer `defun`, `call`, and `return` with the existing `CALL` and `RETURN` opcodes. `while` and the rest of the control-frame layout stay on the AST compiler. +The parser, source expansion, module resolution, patch/context transformations, checker rules, construct-position classification, and public build integration still operate on the AST. The legacy AST bytecode compiler remains the production compiler. Phase 3a teaches the experimental lowerer `defun`, `call`, and `return` with the existing `CALL` and `RETURN` opcodes. Phase 3b teaches it `while` with the existing `JUMP_IF_FALSE` and `JUMP` opcodes. The rest of the control-frame layout stays on the AST compiler. ## Phase-1 executable subset @@ -39,13 +39,14 @@ The direct lowerer supports a deterministic `cli_app` subset: | Deterministic mutation | `map_set`, `map_delete`, `append` | | Observability | `print`, `stderr`, `exit` | | Capability evidence | `env`, using the existing shared capability authority | -| Functions (Phase 3a) | `defun`, `param`, `call`, `return`. Existing `CALL` and `RETURN` opcodes. `while` is not included. | +| Functions (Phase 3a) | `defun`, `param`, `call`, `return`. Existing `CALL` and `RETURN` opcodes. | +| Loops (Phase 3b) | `while`. Control edges are the condition, then the body. Existing `JUMP_IF_FALSE` and `JUMP` opcodes. | ## Unsupported HFIR nodes Every node outside the subset fails closed with one `HFIR_BYTECODE_UNSUPPORTED` error diagnostic. The diagnostic identifies the offending graph node, target `bytecode`, and available source provenance. It returns no `BCProgram`. -Phase 3a moved `defun`, `call`, and `return` into the experimental subset. `while` stays deferred: `LowerToBytecode` still returns `HFIR_BYTECODE_UNSUPPORTED` and no `BCProgram`. Other examples that remain outside a full CFG include loops the lowerer has not been asked to treat as SSA, `try_let` and `catch` where the graph is still not the production source, HTTP routes and lambdas, stores, and model-oriented operations. `ControlEdges` are still empty. This is not a claim that `while` cannot be represented later. +Phase 3a moved `defun`, `call`, and `return` into the experimental subset. Phase 3b moves `while` in as well: `LowerAST` fills that node's `ControlEdges` with the condition and then the body, and `LowerToBytecode` emits the existing jumps. A `while` without those edges still returns `HFIR_BYTECODE_UNSUPPORTED` and no `BCProgram`. Other nodes still have empty `ControlEdges`. Examples that remain outside a full CFG include `for` treated as SSA, `try_let` and `catch` where the graph is still not the production source, HTTP routes and lambdas, stores, and model-oriented operations. This is not a claim that the graph is SSA. ## Bytecode ownership @@ -72,11 +73,11 @@ HowlChangeOps needs 23 runtime constructs plus `catch`; Phase 1 does not support ## HowlBoard compatibility -The existing HowlBoard backend compatibility suite passes against the baseline HowlFrame bytecode compiler, including HTTP request parsing, JSON dict/list behavior, stores, CORS, and network/database capability behavior. Its browser test is blocked locally only because Playwright Chromium is not installed. HowlBoard requires routes and lambdas, HTTP response forms, request parsing, stores, and loops, so it remains outside this experimental subset. Phase 3a covers `defun`, `call`, and `return` on `-compile-hfir-bc` only. The production path is unchanged. +The existing HowlBoard backend compatibility suite passes against the baseline HowlFrame bytecode compiler, including HTTP request parsing, JSON dict/list behavior, stores, CORS, and network/database capability behavior. Its browser test is blocked locally only because Playwright Chromium is not installed. HowlBoard requires routes and lambdas, HTTP response forms, request parsing, stores, and loops, so it remains outside this experimental subset. Phase 3a covers `defun`, `call`, and `return` on `-compile-hfir-bc` only. Phase 3b covers `while` on that same flag. The production path is unchanged. ## What must happen before #88 -Improvement #88 has a real but deliberately bounded execution destination: model-authored graphs that meet the Phase-1 schema can be verified and lowered directly to a deterministic artifact, while unsupported nodes fail closed. Phase 3a adds source-level `defun`, `call`, and `return` on `-compile-hfir-bc`. The model-adapter transport still rejects those kinds, so this slice does not reopen #88. Structured error recovery, `while`, and control-flow edges remain. Work on #88 is still constrained Phase-1 adapter design, not a claim that arbitrary model-authored HFIR can execute today. +Improvement #88 has a real but deliberately bounded execution destination: model-authored graphs that meet the Phase-1 schema can be verified and lowered directly to a deterministic artifact, while unsupported nodes fail closed. Phase 3a adds source-level `defun`, `call`, and `return` on `-compile-hfir-bc`. Phase 3b adds source-level `while` and that loop's control edges on the same flag. The model-adapter transport still rejects those kinds, so this slice does not reopen #88. Structured error recovery and a real CFG remain. Work on #88 is still constrained Phase-1 adapter design, not a claim that arbitrary model-authored HFIR can execute today. ## Lowered ABI v1 (improvement #90, phase 1) @@ -84,7 +85,7 @@ Improvement #88 has a real but deliberately bounded execution destination: model Phase 2a mediates `env` in generated Go and JavaScript. An empty `HOWLFRAME_ALLOW_CAPS` grant is `CAPABILITY_DENIED` and does not read the variable. Phase 2b mediates `exec` the same way: an empty grant, or a grant that omits `process`, is `CAPABILITY_DENIED` before any subprocess starts. Phase 2c mediates `read_file` the same way: an empty grant, or a grant that omits `filesystem`, is `CAPABILITY_DENIED` before any filesystem read. Phase 2d mediates `fetch` the same way: an empty grant, or a grant that omits `network`, is `CAPABILITY_DENIED` before any HTTP request. The production compiler is unchanged. -Wasm feasibility in this revision is the closed set `exec`, `spawn_agent`, and `http_server_start` (`HFIR_TARGET_INFEASIBLE`). Control edges are still unpopulated. Phase 3a makes `defun`, `call`, and `return` executable on `-compile-hfir-bc` only. `while` is still not executable HFIR. Production `-compile-bc` is still the AST. The rest of Phase 2 is one lowered graph for every host. Journals: `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2d_fetch.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase3a_defun_call.md`. +Wasm feasibility in this revision is the closed set `exec`, `spawn_agent`, and `http_server_start` (`HFIR_TARGET_INFEASIBLE`). Phase 3a makes `defun`, `call`, and `return` executable on `-compile-hfir-bc` only. Phase 3b makes `while` executable on that flag and fills `ControlEdges` for the loop header only. Production `-compile-bc` is still the AST. The rest of Phase 2 is one lowered graph for every host. Journals: `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2d_fetch.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase3a_defun_call.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase3b_while.md`. ## Provenance limitation diff --git a/docs/hfir_failure_localization_status.md b/docs/hfir_failure_localization_status.md index a90eac9..bbed17d 100644 --- a/docs/hfir_failure_localization_status.md +++ b/docs/hfir_failure_localization_status.md @@ -15,12 +15,14 @@ and cannot be localized from that map. # Control-flow representation -The existing persisted `ControlEdges` field is not used: it has no role, -direction, ordering, lowering, or model-transport semantics. Phase 3 instead -derives a read-only Phase-1 control view from validated canonical roles: -program and sequence body order, let body continuation, and if then or else -branch containment. No model-supplied control edge is accepted. `exit` remains -terminal runtime behavior rather than an arbitrary graph edge. +Phase 3b writes ordered control successors on a source-lowered `while` +header: the condition, then the body. The experimental lowerer follows that +pair. Localization does not. It still derives a read-only Phase-1 control +view from validated canonical roles: program and sequence body order, let +body continuation, if then or else branch containment, and a `while` only +when those persisted successors match the condition and body roles. No +model-supplied control edge is accepted. `exit` remains terminal runtime +behavior rather than an arbitrary graph edge. # Runtime trace diff --git a/docs/journals/2026-09-30_lowered_hfir_abi_phase3b_while.md b/docs/journals/2026-09-30_lowered_hfir_abi_phase3b_while.md new file mode 100644 index 0000000..c520a3f --- /dev/null +++ b/docs/journals/2026-09-30_lowered_hfir_abi_phase3b_while.md @@ -0,0 +1,38 @@ +# Lowered HFIR ABI, phase 3b: while and control edges on the experimental bytecode path + +## Why this slice + +Phase 3a made `defun`, `call`, and `return` executable on `-compile-hfir-bc`. `while` still failed closed with `HFIR_BYTECODE_UNSUPPORTED`, and `LowerAST` still left `ControlEdges` empty. The production hosts already run `while`. This slice makes the experimental path run that loop, and records the two control successors the jump layout follows. + +This is not a CFG or SSA pass. #90 stays Partial. + +## What landed + +`LowerAST` gives `(while cond body)` a condition data edge, a body data edge, and `ControlEdges` in that order: the test, then the body. The back edge is the existing `JUMP` back to the test. `if`, `for`, `defun`, and every other node stay without control edges. + +`LowerToBytecode` follows those control edges and emits the existing `JUMP_IF_FALSE` and `JUMP` opcodes, with the same relative offsets as the AST bytecode compiler. A `while` whose control edges are missing, swapped, or not the condition and the body returns one `HFIR_BYTECODE_UNSUPPORTED` diagnostic and no `BCProgram`. There is no new opcode and no new capability. A `defun` body may contain `while`. The model-adapter transport still rejects kind `while`, so #88 stays closed. Localization still does not treat a model-supplied control edge as authority. + +`tests/conformance/abi_v1/10_while.howl` is the shared fixture. `tools/difftest` runs it as `while_control`. The false loop must not print. The counting loop prints `1`, `2`, and `3`. + +| Host | How this case reaches it | +| --- | --- | +| `hfir_bytecode` | `-compile-hfir-bc`, then `-run-bc`. Experimental path. | +| `bytecode` | `-compile-bc`, then `-run-bc`. Production AST bytecode. Canonical result. | +| `interpreter`, `go`, `javascript` | Still the AST. The fixture is already in their subset, so they are included. JavaScript rewrites the root to `web_app`. | + +`internal/vm/hfir_equivalence_test.go` also compares the experimental artifact with AST bytecode on a counting loop, a loop that does not enter, a nested loop, a loop inside `defun`, and a non-bool condition. The non-bool condition is `TYPE_ERROR` on both emitters, with empty stdout. The checker rejects that program before either compiler on the CLI; the comparison is the two bytecode paths. + +Production `-compile-bc` is still `runHFIRGate` and then `bytecode.CompileToBytecode` on the AST. `sleep` still compiles on that flag and `-compile-hfir-bc` still rejects it without writing an artifact. + +## What is still open + +* `-compile-bc` still compiles the AST. One lowered graph for every host is still the rest of Phase 2. +* Control edges exist on `while` headers only. The graph is not SSA, and the verifier still does not check cycles. +* `if` and `for` do not grow control edges in this slice. +* Generated `write_file`, `mkdir`, and the other host effects that Phase 2 has not mediated still do not consult a grant. `env`, `exec`, `read_file`, and `fetch` do. +* Feasibility is still a Wasm-only set. This slice adds no Wasm opcode and does not execute Wasm. +* No module linker, no HFIR module graph, and no VM module opcode. + +## What this does not do + +No new opcode. No new capability. #102–#105 and #108 stay Done and are not reopened. #88 stays closed. #90 stays Partial. `write_file` and `mkdir` stay deferred. diff --git a/docs/reference/lowered_hfir_abi_v1.md b/docs/reference/lowered_hfir_abi_v1.md index 631ab6d..c6bbd7e 100644 --- a/docs/reference/lowered_hfir_abi_v1.md +++ b/docs/reference/lowered_hfir_abi_v1.md @@ -61,7 +61,7 @@ Integer `/` is not one rule yet. The interpreter truncates `int64`. The bytecode A `defun` has a name, a parameter list, optional `type_hints`, and a body. `return` leaves the function. `(call name arg ...)` passes arguments by position. The existing harness already compares that shape on the interpreter, the bytecode VM, and Go: `tests/parity/06_control_flow.howl` (`TestParityCorpus`). -Phase 3a makes that shape executable on the experimental lowerer only. `LowerAST` gives `defun` a name, `param` edges, and `body` edges, and erases `type_hint`, `type_hints`, and `type_param`. A return-type symbol between the parameter list and the body is erased the same way the AST bytecode compiler skips it. `call` stores the callee name and `arg` edges. `return` has an optional `value`. `LowerToBytecode` emits the existing `CALL` and `RETURN` opcodes and registers a `BCFunction`. It does not add an opcode. `while` is still `HFIR_BYTECODE_UNSUPPORTED` with no `BCProgram`. `ControlEdges` stay empty. The model-adapter transport still rejects `defun`. +Phase 3a makes that shape executable on the experimental lowerer only. `LowerAST` gives `defun` a name, `param` edges, and `body` edges, and erases `type_hint`, `type_hints`, and `type_param`. A return-type symbol between the parameter list and the body is erased the same way the AST bytecode compiler skips it. `call` stores the callee name and `arg` edges. `return` has an optional `value`. `LowerToBytecode` emits the existing `CALL` and `RETURN` opcodes and registers a `BCFunction`. It does not add an opcode. The model-adapter transport still rejects `defun`. The conformance case `defun_call` is `tests/conformance/abi_v1/09_defun_call.howl`. Its hosts are: @@ -71,7 +71,15 @@ The conformance case `defun_call` is `tests/conformance/abi_v1/09_defun_call.how | `bytecode` | Production `-compile-bc` (AST bytecode after the gate), then `-run-bc`. Canonical result. | | `interpreter`, `go`, `javascript` | Still the AST. Included because this fixture is already in their executable subset. | -Those hosts must print the same stdout. A program the experimental lowerer rejects, including `while`, is not a shared case: the AST hosts run it and `-compile-hfir-bc` fails closed. +Those hosts must print the same stdout. A program the experimental lowerer rejects is not a shared case: the AST hosts run it and `-compile-hfir-bc` fails closed. + +### Loops + +`(while cond body)` re-evaluates `cond` and runs `body` while the condition is true. The body runs zero times when the condition is false. A condition that is not a bool is `TYPE_ERROR` at runtime, and the checker rejects a known non-bool before either compiler. + +Phase 3b makes that shape executable on the experimental lowerer only. `LowerAST` stores a `condition` data edge, a `body` data edge, and `ControlEdges` in that order: the test, then the body. The back edge is the existing `JUMP` to the test. `LowerToBytecode` follows those control edges and emits the existing `JUMP_IF_FALSE` and `JUMP` opcodes, with the same relative offsets as the AST bytecode compiler. A `while` whose control edges are missing or are not that pair fails with `HFIR_BYTECODE_UNSUPPORTED` and no `BCProgram`. It does not add an opcode. `if` and `for` still have empty `ControlEdges`. The model-adapter transport still rejects `while`. + +The conformance case `while_control` is `tests/conformance/abi_v1/10_while.howl`. Its hosts are the same five as `defun_call`. The false loop must not print. The counting loop prints `1`, `2`, and `3`. ### Memory and runtime imports @@ -133,15 +141,15 @@ A later lowering that owns meaning has to be a typed CFG in SSA: * Control edges connect blocks. * Node kinds are constructs, not user binding names. -`hfir.LowerAST` is not that form. It fills data edges for the semantic subset and leaves `ControlEdges` empty on every node, including the v1 arithmetic fixture. Kinds outside `lowerSemanticList` still come from the list head, so a user name can appear as a kind. v1 records that fact. It does not pretend the graph is SSA. +`hfir.LowerAST` is not that form. It fills data edges for the semantic subset. Phase 3b fills `ControlEdges` on a `while` header only: the condition, then the body. The v1 arithmetic fixture has no `while`, so every node in that fixture still has empty `ControlEdges`. `if` and `for` stay empty. Kinds outside `lowerSemanticList` still come from the list head, so a user name can appear as a kind. v1 records that fact. It does not pretend the graph is SSA. ## Deferred (Phase 2) Phase 2 is one lowered graph consumed by every host, with identical outcomes or the same feasibility rejection. -* Production `-compile-bc` still compiles the AST. Flipping that path is still Phase 2. Phase 3a does not flip it. -* `defun`, `call`, and `return` are executable on `-compile-hfir-bc` (Phase 3a). `while` is not. The interpreter, the production bytecode VM, Go, and JavaScript still run calls from the AST. One lowered graph for every host is still open. -* `ControlEdges` are populated and the graph is SSA. Phase 3a does not fill them. +* Production `-compile-bc` still compiles the AST. Flipping that path is still Phase 2. Phase 3a and Phase 3b do not flip it. +* `defun`, `call`, and `return` are executable on `-compile-hfir-bc` (Phase 3a). `while` is executable on that same flag (Phase 3b), with control edges on the loop header only. The interpreter, the production bytecode VM, Go, and JavaScript still run calls and loops from the AST. One lowered graph for every host is still open. +* `ControlEdges` on every control form, and an SSA graph, are still open. Phase 3b fills them for `while` only. * Go and JavaScript mediate `env` (Phase 2a), `exec` (Phase 2b), `read_file` (Phase 2c), and `fetch` (Phase 2d). Other generated host effects, including `write_file` and `mkdir`, still do not. One lowered graph for every host is still the rest of Phase 2. * One feasibility table covers every target, not only the three Wasm host effects. * Non-exact integer division picks one rule. @@ -151,4 +159,4 @@ Phase 2 is one lowered graph consumed by every host, with identical outcomes or ## What the suite does not prove -Agreement among the AST backends is not proof that HFIR is the source of that agreement. `internal/vm/hfir_equivalence_test.go` is separate evidence that the experimental lowerer matches the bytecode VM on the subset it already emits, including `map_keys`, a granted `env`, and Phase 3a `defun` / `call`. That test is not the production compiler. The `defun_call` conformance case compares `-compile-hfir-bc` with the AST hosts on one fixture. Matching stdout there does not mean `-compile-bc` consumes HFIR. +Agreement among the AST backends is not proof that HFIR is the source of that agreement. `internal/vm/hfir_equivalence_test.go` is separate evidence that the experimental lowerer matches the bytecode VM on the subset it already emits, including `map_keys`, a granted `env`, Phase 3a `defun` / `call`, and Phase 3b `while`. That test is not the production compiler. The `defun_call` and `while_control` conformance cases compare `-compile-hfir-bc` with the AST hosts on those fixtures. Matching stdout there does not mean `-compile-bc` consumes HFIR. diff --git a/howlframe.go b/howlframe.go index 0c11a9b..3a55759 100644 --- a/howlframe.go +++ b/howlframe.go @@ -490,9 +490,9 @@ var hfirBlockingCodes = map[string]bool{ // reference and wasm-target feasibility; capability-effect inference always // runs as a Verify() side effect but never itself produces a blocking // diagnostic) - real control-flow/cycle verification and non-wasm target -// feasibility are not implemented in internal/hfir yet (ControlEdges is -// declared but never populated by LowerAST, and isFeasible only has rules -// for "wasm"). +// feasibility are not implemented in internal/hfir yet. LowerAST fills +// ControlEdges for a while header only (condition, then body). That is not +// a CFG, and isFeasible only has rules for "wasm". func runHFIRGate(root *ast.Node, module string, target hfirTarget) *hfir.Graph { graph, err := hfir.LowerAST(root, module) if err != nil { diff --git a/howlframe_test.go b/howlframe_test.go index d670372..afba397 100644 --- a/howlframe_test.go +++ b/howlframe_test.go @@ -1239,10 +1239,12 @@ func TestCompileBcFailsClosedOnUnsupportedConstruct(t *testing.T) { } } -// TestCompileBcStaysASTWhileHfirBcRejectsWhile locks the production flag. -// -compile-bc still emits AST bytecode for while. -compile-hfir-bc still -// rejects while and writes no artifact. Phase 3a does not flip that flag. -func TestCompileBcStaysASTWhileHfirBcRejectsWhile(t *testing.T) { +// TestCompileBcStaysASTWhileHfirBcRunsWhile locks the production flag. +// Both flags compile and run a tiny while to the same stdout. -compile-bc +// is still the AST compiler: sleep is in that compiler and still outside +// the experimental lowerer, so -compile-hfir-bc rejects it and writes no +// artifact. Phase 3b does not flip -compile-bc. +func TestCompileBcStaysASTWhileHfirBcRunsWhile(t *testing.T) { howlframeBinary := filepath.Join(t.TempDir(), "howlframe") if output, err := exec.Command("go", "build", "-o", howlframeBinary, ".").CombinedOutput(); err != nil { t.Fatalf("failed to build HowlFrame binary: %v\n%s", output, err) @@ -1262,19 +1264,39 @@ func TestCompileBcStaysASTWhileHfirBcRejectsWhile(t *testing.T) { if err != nil { t.Fatalf("-run-bc of production while artifact: %v\n%s", err, runOut) } - if !strings.Contains(string(runOut), "1") { + if strings.TrimSpace(string(runOut)) != "1" { t.Fatalf("production while stdout = %q", runOut) } hfirOut := filepath.Join(dir, "hfir.bc.bin") - output, err := exec.Command(howlframeBinary, "-compile-hfir-bc", source, "-o", hfirOut).CombinedOutput() + if output, err := exec.Command(howlframeBinary, "-compile-hfir-bc", source, "-o", hfirOut).CombinedOutput(); err != nil { + t.Fatalf("-compile-hfir-bc rejected while: %v\n%s", err, output) + } + hfirRun, err := exec.Command(howlframeBinary, "-run-bc", hfirOut).CombinedOutput() + if err != nil { + t.Fatalf("-run-bc of experimental while artifact: %v\n%s", err, hfirRun) + } + if string(hfirRun) != string(runOut) { + t.Fatalf("experimental while stdout = %q, production = %q", hfirRun, runOut) + } + + sleepSource := filepath.Join(dir, "sleep.howl") + if err := os.WriteFile(sleepSource, []byte("(cli_app (sleep 0))\n"), 0o644); err != nil { + t.Fatal(err) + } + sleepOut := filepath.Join(dir, "sleep.bc.bin") + if output, err := exec.Command(howlframeBinary, "-compile-bc", sleepSource, "-o", sleepOut).CombinedOutput(); err != nil { + t.Fatalf("-compile-bc rejected sleep, so production is not the AST compiler: %v\n%s", err, output) + } + rejected := filepath.Join(dir, "sleep-hfir.bc.bin") + output, err := exec.Command(howlframeBinary, "-compile-hfir-bc", sleepSource, "-o", rejected).CombinedOutput() if err == nil { - t.Fatalf("-compile-hfir-bc accepted while:\n%s", output) + t.Fatalf("-compile-hfir-bc accepted sleep:\n%s", output) } - if !strings.Contains(string(output), "while") { + if !strings.Contains(string(output), "sleep") { t.Fatalf("experimental rejection = %s", output) } - if _, statErr := os.Stat(hfirOut); !os.IsNotExist(statErr) { + if _, statErr := os.Stat(rejected); !os.IsNotExist(statErr) { t.Fatalf("experimental rejection wrote an artifact: %v", statErr) } } diff --git a/improvements.md b/improvements.md index 5a698d8..cee7f58 100644 --- a/improvements.md +++ b/improvements.md @@ -45,7 +45,7 @@ Pending rows are ranked by a diminishing-returns score: | 106 | [Bytecode modules: design spike only](#106-bytecode-modules-design-spike-only) | Done (2026-09-30) | 1.50 (6×1÷4) | Opus 5 | Gemini 3.1 Pro (High) | gpt-5.6-sol | Do not build a bytecode-tier linker yet. Flat multi-file programs already run on `-compile-bc` / `-run-bc` through #95. A half linker and an HFIR module patch are refused. Journal: `docs/journals/2026-09-30_bytecode_modules_spike.md`. | | 108 | [Fail-closed TYPE_ERROR on remaining dict and list ops](#108-fail-closed-type_error-on-remaining-dict-and-list-ops) | Done (2026-09-29) | 1.50 (6×1÷4) | Sonnet 5 | Gemini 3.1 Pro (High) | gpt-5.6-sol | `append`, `map_set`, `map_delete`, `map_get`, `list_get`, and `list_len` reject a wrong receiver with `TYPE_ERROR` on the VM, Go, and JS. A missing `map_get` key is still `""`. No new capability. Journal: `docs/journals/2026-09-29_type_error_collections.md`. | | 88 | [Define a provider-neutral HFIR model-adapter protocol](#88-define-a-provider-neutral-hfir-model-adapter-protocol) | Pending | 1.5 (6×1÷4) | Opus 5 | — | gpt-5.6-terra | Mask plans are provider-neutral but no adapter produces or repairs a verified semantic program artifact. A schema, constrained-decoding boundary, and delta protocol prevents lock-in and reduces regeneration cost. | -| 90 | [Define the lowered-HFIR backend ABI and conformance suite](#90-define-the-lowered-hfir-backend-abi-and-conformance-suite) | Partial — Phase 1 Done, Phase 2a Done, Phase 2b (exec) Done, Phase 2c (read_file) Done, Phase 2d (fetch) Done (2026-09-30); Phase 2 remaining | 1.5 (6×1÷4) | Opus 5 | — | gpt-5.6-sol | Phase 1 is the versioned ABI and the difftest suite. Phase 2a mediates `env`. Phase 2b mediates `exec`. Phase 2c mediates `read_file` on generated Go and JavaScript. Phase 2d mediates `fetch` on those hosts. Production execution is still AST → bytecode. One lowered graph and Wasm stay in Phase 2. Phase 3a runs `defun` and `call` on the experimental bytecode path only. #90 is not Done. Journals: `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2d_fetch.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase3a_defun_call.md`. | +| 90 | [Define the lowered-HFIR backend ABI and conformance suite](#90-define-the-lowered-hfir-backend-abi-and-conformance-suite) | Partial — Phase 1 Done, Phase 2a Done, Phase 2b (exec) Done, Phase 2c (read_file) Done, Phase 2d (fetch) Done (2026-09-30); Phase 2 remaining | 1.5 (6×1÷4) | Opus 5 | — | gpt-5.6-sol | Phase 1 is the versioned ABI and the difftest suite. Phase 2a mediates `env`. Phase 2b mediates `exec`. Phase 2c mediates `read_file` on generated Go and JavaScript. Phase 2d mediates `fetch` on those hosts. Production execution is still AST → bytecode. One lowered graph and Wasm stay in Phase 2. Phase 3a runs `defun` and `call` on the experimental bytecode path only. Phase 3b runs `while` on that path and fills control edges for the loop header. #90 is not Done. Journals: `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2d_fetch.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase3a_defun_call.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase3b_while.md`. | | 89 | [Add content-addressed HFIR storage and incremental compilation](#89-add-content-addressed-hfir-storage-and-incremental-compilation) | Pending | 1.4 (7×1÷5) | Opus 5 | — | gpt-5.6-sol | Stable graph identities permit small repairs, dependency-closure recompilation, reproducible artifacts, and bounded model context instead of whole-program regeneration. | | 95 | [Standalone Runtime Module Use/Export](#95-standalone-runtime-module-use-export) | Done (2026-08-08) | 1.33 (8×0.5÷3) | Sonnet 5 | Gemini 3.1 Pro (High) | — | Multi-file `cli_app` compilation now works through `-compile-bc`/`-run-bc`. `ast.ResolveModules`'s existing compile-time AST linker (improvement #93) already handled the standalone path; closed the nested-import gap with a fail-closed boundary and reclassified `use`/`export`/`module` as `CompileTimeOnly`. Journal: `docs/journals/2026-08-08_improvement_95_standalone_modules.md`. | | 109 | [Dict key sort parity across VM, Go, and JS, including non-BMP](#109-dict-key-sort-parity-across-vm-go-and-js-including-non-bmp) | Done (2026-09-30) | 1.33 (4×1÷3) | Sonnet 5 | Gemini 3.1 Pro (High) | gpt-5.6-sol | `map_keys` order is UTF-8 byte order on the VM, Go, and JS, including non-BMP keys. JavaScript no longer uses UTF-16 code-unit sort. #73 must reuse this order. Journal: `docs/journals/2026-09-30_dict_key_sort.md`. | @@ -788,14 +788,14 @@ As HowlFrame matures past transpilation into Go and JS, the ultimate objective i * **Required tests:** invalidation, hash determinism, corruption recovery, and reproducible-build integration. ### 90. Define the lowered-HFIR backend ABI and conformance suite -* **Status:** Partial — Phase 1 Done, Phase 2a Done, Phase 2b (exec) Done, Phase 2c (read_file) Done, Phase 2d (fetch) Done (2026-09-30); Phase 2 remaining. The contract is `docs/reference/lowered_hfir_abi_v1.md` (`lowered-hfir-abi/v1`). The suite is `tests/conformance/lowered_hfir_abi_v1.json`, executed by `tools/difftest` on the interpreter, the bytecode VM, Go, and JavaScript where that host applies. Wasm's v1 rule is `HFIR_TARGET_INFEASIBLE` for `exec`, `spawn_agent`, and `http_server_start` only. Production `-compile-bc` is still `runHFIRGate` and then `bytecode.CompileToBytecode` on the AST. `-compile-hfir-bc` stays the experimental lowerer. `ControlEdges` are still empty. Phase 3a: `-compile-hfir-bc` lowers `defun`, `call`, and `return` onto the existing `CALL` and `RETURN` opcodes. `while` is still not executable HFIR. The model-adapter transport still rejects `defun`. Phase 2a: generated Go and JavaScript mediate `env` the same way the interpreter and the bytecode VM do. An empty grant is `CAPABILITY_DENIED` and does not read the variable. Phase 2b: those hosts mediate `exec` the same way. The grant name is `process`. An empty or missing grant, or a grant that omits `process`, is `CAPABILITY_DENIED` before any subprocess starts, and the denial does not include the command. The `process` grant runs the command. Phase 2c: those hosts mediate `read_file` the same way. The grant name is `filesystem`. An empty or missing grant, or a grant that omits `filesystem`, is `CAPABILITY_DENIED` before any filesystem read, and the denial does not include the path. The `filesystem` grant reads the file. Phase 2d: those hosts mediate `fetch` the same way. The grant name is `network`. An empty or missing grant, or a grant that omits `network`, is `CAPABILITY_DENIED` before any HTTP request, and the denial does not include the URL. The `network` grant performs the request. Other generated host effects, including `write_file` and `mkdir`, are still unmediated. Phase 2 is one lowered graph for every host. #90 is not Done. Journals: `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2d_fetch.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase3a_defun_call.md`. +* **Status:** Partial — Phase 1 Done, Phase 2a Done, Phase 2b (exec) Done, Phase 2c (read_file) Done, Phase 2d (fetch) Done (2026-09-30); Phase 2 remaining. The contract is `docs/reference/lowered_hfir_abi_v1.md` (`lowered-hfir-abi/v1`). The suite is `tests/conformance/lowered_hfir_abi_v1.json`, executed by `tools/difftest` on the interpreter, the bytecode VM, Go, and JavaScript where that host applies. Wasm's v1 rule is `HFIR_TARGET_INFEASIBLE` for `exec`, `spawn_agent`, and `http_server_start` only. Production `-compile-bc` is still `runHFIRGate` and then `bytecode.CompileToBytecode` on the AST. `-compile-hfir-bc` stays the experimental lowerer. Phase 3a: `-compile-hfir-bc` lowers `defun`, `call`, and `return` onto the existing `CALL` and `RETURN` opcodes. Phase 3b: that flag lowers `while` onto the existing `JUMP_IF_FALSE` and `JUMP` opcodes, and `ControlEdges` on a while header are the condition and then the body. Other nodes still have empty control edges. The model-adapter transport still rejects `defun` and `while`. Phase 2a: generated Go and JavaScript mediate `env` the same way the interpreter and the bytecode VM do. An empty grant is `CAPABILITY_DENIED` and does not read the variable. Phase 2b: those hosts mediate `exec` the same way. The grant name is `process`. An empty or missing grant, or a grant that omits `process`, is `CAPABILITY_DENIED` before any subprocess starts, and the denial does not include the command. The `process` grant runs the command. Phase 2c: those hosts mediate `read_file` the same way. The grant name is `filesystem`. An empty or missing grant, or a grant that omits `filesystem`, is `CAPABILITY_DENIED` before any filesystem read, and the denial does not include the path. The `filesystem` grant reads the file. Phase 2d: those hosts mediate `fetch` the same way. The grant name is `network`. An empty or missing grant, or a grant that omits `network`, is `CAPABILITY_DENIED` before any HTTP request, and the denial does not include the URL. The `network` grant performs the request. Other generated host effects, including `write_file` and `mkdir`, are still unmediated. Phase 2 is one lowered graph for every host. #90 is not Done. Journals: `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2d_fetch.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase3a_defun_call.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase3b_while.md`. * **Description:** Specify a target-independent lowered HFIR ABI for typed CFG/SSA, calls, memory/runtime imports, errors, effects, and capability boundaries. Migrate a deterministic core subset and compare interpreter, bytecode, Go, JS where applicable, and Wasm outcomes or explicit rejections. -* **Current reality (recorded 2026-09-30):** HFIR is still not the production execution source. `-compile-bc` verifies a lowered graph and then compiles the AST. The experimental `-compile-hfir-bc` path can emit bytecode for a semantic subset (`let`, `if`, `map_keys`, `env`, and the forms in `internal/hfir/bytecode.go`) and fails closed with `HFIR_BYTECODE_UNSUPPORTED` outside it, including `while`. Phase 3a adds `defun`, `call`, and `return` to that experimental subset. `LowerAST` still does not populate `ControlEdges`. On 2026-08-07 that direct path did not exist; it exists now as research, not as the compiler. Go, JavaScript, and the interpreter still consume the AST. The v1 suite compares those hosts on a pure core and on capability denial. Phase 2a puts generated Go and JavaScript on the `env` denial and granted cases. Phase 2b puts those hosts on the `exec` denial and granted cases. Phase 2c puts those hosts on the `read_file` denial and granted cases. Phase 2d puts those hosts on the `fetch` denial and granted cases. Phase 3a runs `defun` and `call` on `-compile-hfir-bc` only. It does not flip the execution path. +* **Current reality (recorded 2026-09-30):** HFIR is still not the production execution source. `-compile-bc` verifies a lowered graph and then compiles the AST. The experimental `-compile-hfir-bc` path can emit bytecode for a semantic subset (`let`, `if`, `map_keys`, `env`, and the forms in `internal/hfir/bytecode.go`) and fails closed with `HFIR_BYTECODE_UNSUPPORTED` outside it. Phase 3a adds `defun`, `call`, and `return` to that experimental subset. Phase 3b adds `while`, and `LowerAST` fills `ControlEdges` for that header only. On 2026-08-07 that direct path did not exist; it exists now as research, not as the compiler. Go, JavaScript, and the interpreter still consume the AST. The v1 suite compares those hosts on a pure core and on capability denial. Phase 2a puts generated Go and JavaScript on the `env` denial and granted cases. Phase 2b puts those hosts on the `exec` denial and granted cases. Phase 2c puts those hosts on the `read_file` denial and granted cases. Phase 2d puts those hosts on the `fetch` denial and granted cases. Phase 3a runs `defun` and `call` on `-compile-hfir-bc` only. Phase 3b runs `while` on that flag and does not fill control edges for any other node. It does not flip the execution path. * **Why:** Each current backend owns overlapping semantics. More backend expansion without a contract will amplify drift. * **Dependencies:** #86 and #87; incorporates #78. #73 and #84 should target this ABI. * **Acceptance criteria:** one lowering runs equivalently on each supported target; unsupported effects use the same feasibility contract. Phase 1 meets the second clause for the closed Wasm host-effect set and records identical outcomes for the core subset on the hosts that already implement it. The first clause, one lowering as the source for every host, is Phase 2. * **Required tests:** differential and property-based deterministic fixtures plus negative capability tests. Phase 1 adds those on the existing difftest harness. `tests/parity` remains the broader AST corpus, including calls. -* **Team backlog (2026-09-29, updated 2026-09-30):** Phase 1 is in. Phase 2a mediates `env`. Phase 2b mediates `exec` on Go and JavaScript. Phase 2c mediates `read_file` on Go and JavaScript. Phase 2d mediates `fetch` on Go and JavaScript. Phase 3a lowers `defun` and `call` on the experimental bytecode path only; `while` stays deferred. `write_file`, `mkdir`, and the other host effects stay open. Wasm expansion stays gated on the rest of Phase 2; do not grow a thin Wasm backend ahead of it. See `docs/journals/2026-09-29_team_howlframe_backlog.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2d_fetch.md`, and `docs/journals/2026-09-30_lowered_hfir_abi_phase3a_defun_call.md`. +* **Team backlog (2026-09-29, updated 2026-09-30):** Phase 1 is in. Phase 2a mediates `env`. Phase 2b mediates `exec` on Go and JavaScript. Phase 2c mediates `read_file` on Go and JavaScript. Phase 2d mediates `fetch` on Go and JavaScript. Phase 3a lowers `defun` and `call` on the experimental bytecode path only. Phase 3b lowers `while` and that loop's control edges on the same path. `write_file`, `mkdir`, and the other host effects stay open. Wasm expansion stays gated on the rest of Phase 2; do not grow a thin Wasm backend ahead of it. See `docs/journals/2026-09-29_team_howlframe_backlog.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase1.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2a_env.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2b_exec.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2c_read_file.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase2d_fetch.md`, `docs/journals/2026-09-30_lowered_hfir_abi_phase3a_defun_call.md`, and `docs/journals/2026-09-30_lowered_hfir_abi_phase3b_while.md`. ### 91. Add semantic patch deltas and bounded repair context * **Description:** Make autonomous repair operate on HFIR deltas addressed to stable IDs. Require preconditions, touched dependencies, expected invariants, and regression evidence, with compact context extracted from graph neighborhoods and diagnostics. diff --git a/internal/hfir/abi_v1_test.go b/internal/hfir/abi_v1_test.go index e23c05a..0d423b4 100644 --- a/internal/hfir/abi_v1_test.go +++ b/internal/hfir/abi_v1_test.go @@ -5,6 +5,7 @@ import ( "path/filepath" "testing" + "github.com/howlcipher/howlframe/internal/bytecode" "github.com/howlcipher/howlframe/internal/checker" "github.com/howlcipher/howlframe/internal/lexer" "github.com/howlcipher/howlframe/internal/parser" @@ -69,7 +70,7 @@ func TestLoweredABIV1CoreFixtureHasNoControlEdges(t *testing.T) { } for _, node := range graph.Nodes { if len(node.ControlEdges) != 0 { - t.Fatalf("node %s kind %s has control edges %v; v1 LowerAST does not populate CFG edges", node.ID, node.Kind, node.ControlEdges) + t.Fatalf("node %s kind %s has control edges %v; the arith fixture has no while", node.ID, node.Kind, node.ControlEdges) } } program, diags := LowerToBytecode(graph) @@ -81,25 +82,40 @@ func TestLoweredABIV1CoreFixtureHasNoControlEdges(t *testing.T) { } } -func TestLoweredABIV1WhileIsNotExecutable(t *testing.T) { - sources := []string{ - `(cli_app (while false (print "no")))`, - } - for _, source := range sources { - root := parser.NewParser(lexer.NewLexer(source), "abi_deferred.howl").ParseExpression() - checker.Check(root) - graph, err := LowerAST(root, "abi_deferred.howl") - if err != nil { - t.Fatalf("LowerAST(%q) error = %v", source, err) +func TestLoweredABIV1WhileIsExecutable(t *testing.T) { + source := `(cli_app (while false (print "no")))` + root := parser.NewParser(lexer.NewLexer(source), "abi_while.howl").ParseExpression() + checker.Check(root) + graph, err := LowerAST(root, "abi_while.howl") + if err != nil { + t.Fatalf("LowerAST(%q) error = %v", source, err) + } + var loops int + for _, node := range graph.Nodes { + if node.Kind != "while" { + if len(node.ControlEdges) != 0 { + t.Fatalf("node %s kind %s has control edges; only while is in this slice", node.ID, node.Kind) + } + continue } - program, diags := LowerToBytecode(graph) - if program != nil { - t.Fatalf("LowerToBytecode(%q) returned a program; v1 must fail closed", source) + loops++ + if len(node.ControlEdges) != 2 || len(node.DataInputs) != 2 || node.DataInputs[0].Name != "condition" || node.DataInputs[1].Name != "body" { + t.Fatalf("while shape = %#v", node) } - if len(diags) == 0 || diags[0].Code != BytecodeUnsupportedCode { - t.Fatalf("LowerToBytecode(%q) diags = %#v, want %s", source, diags, BytecodeUnsupportedCode) + if node.ControlEdges[0] != node.DataInputs[0].SourceNode || node.ControlEdges[1] != node.DataInputs[1].SourceNode { + t.Fatalf("while control edges = %v, data = %#v", node.ControlEdges, node.DataInputs) } } + if loops != 1 { + t.Fatalf("while nodes = %d", loops) + } + program, diags := LowerToBytecode(graph) + if program == nil || len(diags) != 0 { + t.Fatalf("LowerToBytecode(%q) program=%v diags=%#v", source, program != nil, diags) + } + if err := bytecode.ValidateProgram(program); err != nil { + t.Fatal(err) + } } func lowerFixture(t *testing.T, path string) *Graph { diff --git a/internal/hfir/bytecode.go b/internal/hfir/bytecode.go index 5d95fce..45652b1 100644 --- a/internal/hfir/bytecode.go +++ b/internal/hfir/bytecode.go @@ -69,7 +69,7 @@ func (c *bytecodeLowerer) compile(node *Node) (instructions []bytecode.BCInstruc } // A defun registers a BCFunction. That side effect is not stored in the // instruction cache, so a preserved defun is lowered again and the - // function table is rebuilt. while stays outside this subset. + // function table is rebuilt. while has no function-table side effect. if node.Kind != "defun" { if c.cachedNodes != nil && c.cachedNodes[node.ID] != nil { if c.reusedNodes != nil { @@ -452,6 +452,31 @@ func (c *bytecodeLowerer) compile(node *Node) (instructions []bytecode.BCInstruc inst.IntOperand = int64(-(len(insts) - loopStart)) })) return insts, nil + case "while": + condNode, bodyNode, shapeDiagnostic := c.whileSuccessors(node) + if shapeDiagnostic != nil { + return nil, shapeDiagnostic + } + condInsts, childDiagnostic := c.compile(condNode) + if childDiagnostic != nil { + return nil, childDiagnostic + } + bodyInsts, childDiagnostic := c.compile(bodyNode) + if childDiagnostic != nil { + return nil, childDiagnostic + } + // Same relative jumps as bytecode.CompileToBytecode's while case. + // JUMP_IF_FALSE skips the body and the back edge. JUMP returns to + // the condition. No new opcode. + insts := append([]bytecode.BCInstruction{}, condInsts...) + insts = append(insts, instruction(bytecode.OpJumpIfFalse, "JUMP_IF_FALSE", func(inst *bytecode.BCInstruction) { + inst.IntOperand = int64(len(bodyInsts) + 2) + })) + insts = append(insts, bodyInsts...) + insts = append(insts, instruction(bytecode.OpJump, "JUMP", func(inst *bytecode.BCInstruction) { + inst.IntOperand = int64(-(len(condInsts) + 1 + len(bodyInsts))) + })) + return insts, nil case "defun": if node.Value == "" { diagnostic := c.diagnostic(node, "defun requires a name") @@ -537,6 +562,24 @@ func (c *bytecodeLowerer) compile(node *Node) (instructions []bytecode.BCInstruc } } +// whileSuccessors is the executable control-edge contract for a loop header. +// ControlEdges[0] is the condition and ControlEdges[1] is the body. They must +// be the same nodes as the named data edges. A while without those edges is +// not executable. +func (c *bytecodeLowerer) whileSuccessors(node *Node) (*Node, *Node, *Diagnostic) { + if len(node.ControlEdges) != 2 || len(node.DataInputs) != 2 || node.DataInputs[0].Name != "condition" || node.DataInputs[1].Name != "body" || node.ControlEdges[0] != node.DataInputs[0].SourceNode || node.ControlEdges[1] != node.DataInputs[1].SourceNode { + diagnostic := c.diagnostic(node, "while requires a condition control edge and a body control edge") + return nil, nil, &diagnostic + } + cond := c.graph.NodeByID(node.ControlEdges[0]) + body := c.graph.NodeByID(node.ControlEdges[1]) + if cond == nil || body == nil { + diagnostic := c.diagnostic(node, "while control edge references a missing node") + return nil, nil, &diagnostic + } + return cond, body, nil +} + func (c *bytecodeLowerer) children(node *Node) ([]*Node, *Diagnostic) { children := make([]*Node, 0, len(node.DataInputs)) for _, edge := range node.DataInputs { diff --git a/internal/hfir/defun_call_test.go b/internal/hfir/defun_call_test.go index 76c467c..9d29b42 100644 --- a/internal/hfir/defun_call_test.go +++ b/internal/hfir/defun_call_test.go @@ -17,7 +17,7 @@ func TestLowerToBytecodeEmitsDefunAndCall(t *testing.T) { (print (call add 2 3)))`) for _, node := range graph.Nodes { if len(node.ControlEdges) != 0 { - t.Fatalf("node %s kind %s has control edges; Phase 3a does not build a CFG", node.ID, node.Kind) + t.Fatalf("node %s kind %s has control edges; this fixture has no while", node.ID, node.Kind) } if node.Kind == "type_hints" || node.Kind == "type_hint" || node.Kind == "while" { t.Fatalf("erased or deferred kind %s was lowered", node.Kind) @@ -121,10 +121,8 @@ func TestLowerToBytecodeDefunFromGraphWithoutAST(t *testing.T) { } } -func TestLowerToBytecodeStillRejectsWhileAndLambda(t *testing.T) { +func TestLowerToBytecodeStillRejectsLambda(t *testing.T) { for _, source := range []string{ - `(cli_app (while false (print "no")))`, - `(cli_app (defun f () (while false (return 1))) (print (call f)))`, `(cli_app (lambda (x) (print x)))`, } { root := parser.NewParser(lexer.NewLexer(source), "deferred.howl").ParseExpression() @@ -151,7 +149,7 @@ func TestASTBytecodeStillCompilesWhile(t *testing.T) { t.Fatal(err) } program, diags := LowerToBytecode(graph) - if program != nil || len(diags) == 0 || diags[0].Code != BytecodeUnsupportedCode { + if program == nil || len(diags) != 0 { t.Fatalf("experimental while = (%v, %#v)", program != nil, diags) } astProgram := bytecode.CompileToBytecode(root) diff --git a/internal/hfir/localization.go b/internal/hfir/localization.go index bb6cfa7..5547d64 100644 --- a/internal/hfir/localization.go +++ b/internal/hfir/localization.go @@ -110,6 +110,16 @@ func DerivePhase1ControlRelations(graph *Graph) []DerivedControlRelation { } } } + case "while": + // The relation exists only when the persisted control successors + // are the condition and then the body. A while with data roles + // and no control edges yields nothing. + if len(node.DataInputs) == 2 && node.DataInputs[0].Name == "condition" && node.DataInputs[1].Name == "body" && len(node.ControlEdges) == 2 && node.ControlEdges[0] == node.DataInputs[0].SourceNode && node.ControlEdges[1] == node.DataInputs[1].SourceNode && graph.NodeByID(node.ControlEdges[0]) != nil && graph.NodeByID(node.ControlEdges[1]) != nil { + relations = append(relations, + DerivedControlRelation{Controller: node.ID, Controlled: node.ControlEdges[0], Role: "condition", Ordinal: 0}, + DerivedControlRelation{Controller: node.ID, Controlled: node.ControlEdges[1], Role: "body", Ordinal: 1}, + ) + } } } sort.Slice(relations, func(i, j int) bool { diff --git a/internal/hfir/lowering.go b/internal/hfir/lowering.go index 7c76a7c..20c0e86 100644 --- a/internal/hfir/lowering.go +++ b/internal/hfir/lowering.go @@ -377,9 +377,32 @@ func (ctx *LoweringContext) lowerSemanticList(node *Node, astNode *ast.Node, hea child *ast.Node }{{"iterable", astNode.Children[2]}, {"body", astNode.Children[3]}}) return id, true, err + case "while": + // (while cond body). ControlEdges are the header successors: the + // test, then the body. The back edge is the JUMP the lowerer emits + // to the test. Other nodes stay without control edges. + if len(astNode.Children) != 3 { + return "", false, nil + } + node.Kind = "while" + id := ctx.Graph.AddNode(node) + condID, err := ctx.lowerNode(astNode.Children[1]) + if err != nil { + return "", true, err + } + bodyID, err := ctx.lowerNode(astNode.Children[2]) + if err != nil { + return "", true, err + } + node.DataInputs = append(node.DataInputs, + DataEdge{Name: "condition", SourceNode: condID}, + DataEdge{Name: "body", SourceNode: bodyID}, + ) + node.ControlEdges = []NodeID{condID, bodyID} + return id, true, nil case "defun": // (defun name (params) [return-type-symbol] body...) - // type_hint forms in the body are erased. while is not this case. + // type_hint forms in the body are erased. if len(astNode.Children) < 4 || astNode.Children[1].Type != "SYMBOL" || astNode.Children[1].Value == "" || astNode.Children[2].Type != "List" { return "", false, nil } diff --git a/internal/hfir/model_adapter.go b/internal/hfir/model_adapter.go index f2c521d..b3ee454 100644 --- a/internal/hfir/model_adapter.go +++ b/internal/hfir/model_adapter.go @@ -721,6 +721,7 @@ func nodeRoles(kind string) []string { default: // defun, call, return, param, and while are not transport kinds. // Phase 3a lowers defun and call from source on -compile-hfir-bc. + // Phase 3b lowers while from source on that same flag. // This allow-list stays the Phase-1 adapter subset (#88). return nil } diff --git a/internal/hfir/verifier.go b/internal/hfir/verifier.go index 6bde567..b567997 100644 --- a/internal/hfir/verifier.go +++ b/internal/hfir/verifier.go @@ -83,6 +83,9 @@ func (v *Verifier) Verify() []Diagnostic { } checkRole("iterable", true) checkRole("body", true) + case "while": + checkRole("condition", true) + checkRole("body", true) case "read_file": checkRole("path", true) case "parse_json": diff --git a/internal/hfir/while_test.go b/internal/hfir/while_test.go new file mode 100644 index 0000000..0936cca --- /dev/null +++ b/internal/hfir/while_test.go @@ -0,0 +1,193 @@ +package hfir + +import ( + "testing" + + "github.com/howlcipher/howlframe/internal/bytecode" + "github.com/howlcipher/howlframe/internal/checker" + "github.com/howlcipher/howlframe/internal/lexer" + "github.com/howlcipher/howlframe/internal/parser" +) + +func TestLowerToBytecodeEmitsWhileControlEdges(t *testing.T) { + graph := lowerCheckedWhile(t, `(cli_app + (let (n 0) + (while (< n 2) + (do + (set n (+ n 1)) + (if (< n 2) + (print n) + (print "last"))))))`) + var loop *Node + for _, node := range graph.Nodes { + if node.Kind == "while" { + if loop != nil { + t.Fatal("expected one while") + } + loop = node + continue + } + if node.Kind == "if" && len(node.ControlEdges) != 0 { + t.Fatalf("if %s has control edges %v; this slice fills them on while only", node.ID, node.ControlEdges) + } + } + if loop == nil { + t.Fatal("missing while") + } + if len(loop.ControlEdges) != 2 || loop.DataInputs[0].Name != "condition" || loop.DataInputs[1].Name != "body" { + t.Fatalf("while = %#v", loop) + } + if loop.ControlEdges[0] != loop.DataInputs[0].SourceNode || loop.ControlEdges[1] != loop.DataInputs[1].SourceNode { + t.Fatalf("control %v data %#v", loop.ControlEdges, loop.DataInputs) + } + cond := graph.NodeByID(loop.ControlEdges[0]) + body := graph.NodeByID(loop.ControlEdges[1]) + if cond == nil || cond.Kind != "binary" || body == nil || body.Kind != "sequence" { + t.Fatalf("successors cond=%#v body=%#v", cond, body) + } + relations := DerivePhase1ControlRelations(graph) + var sawCond, sawBody bool + for _, relation := range relations { + if relation.Controller != loop.ID { + continue + } + switch relation.Role { + case "condition": + sawCond = relation.Controlled == loop.ControlEdges[0] && relation.Ordinal == 0 + case "body": + sawBody = relation.Controlled == loop.ControlEdges[1] && relation.Ordinal == 1 + } + } + if !sawCond || !sawBody { + t.Fatalf("while relations = %#v", relations) + } + + program, diags := LowerToBytecode(graph) + if len(diags) != 0 || program == nil { + t.Fatalf("LowerToBytecode() diags=%#v", diags) + } + if !hasOp(program.Main, bytecode.OpJumpIfFalse) || !hasOp(program.Main, bytecode.OpJump) { + t.Fatalf("main = %#v", program.Main) + } + if err := bytecode.ValidateProgram(program); err != nil { + t.Fatal(err) + } +} + +func TestLowerToBytecodeWhileInsideDefun(t *testing.T) { + graph := lowerCheckedWhile(t, `(cli_app + (defun steps (limit) + (type_hints (limit int) (return int)) + (let (n 0) + (do + (while (< n limit) + (set n (+ n 1))) + (return n)))) + (print (call steps 4)))`) + var loop *Node + for _, node := range graph.Nodes { + if node.Kind == "while" { + loop = node + } + if node.Kind == "defun" && len(node.ControlEdges) != 0 { + t.Fatalf("defun has control edges %v", node.ControlEdges) + } + } + if loop == nil || len(loop.ControlEdges) != 2 { + t.Fatalf("while = %#v", loop) + } + program, diags := LowerToBytecode(graph) + if len(diags) != 0 || program == nil || program.Functions["steps"] == nil { + t.Fatalf("program=%v diags=%#v", program != nil, diags) + } + fn := program.Functions["steps"].Instructions + if !hasOp(fn, bytecode.OpJumpIfFalse) || !hasOp(fn, bytecode.OpReturn) { + t.Fatalf("function = %#v", fn) + } +} + +func TestLowerToBytecodeWhileFromGraphWithoutAST(t *testing.T) { + graph := NewGraph() + cond := graph.AddNode(&Node{Kind: "const", LiteralKind: "BOOL", Value: "false"}) + text := graph.AddNode(&Node{Kind: "const", LiteralKind: "STRING", Value: "no"}) + body := graph.AddNode(&Node{Kind: "print", DataInputs: []DataEdge{{Name: "value", SourceNode: text}}}) + loop := graph.AddNode(&Node{ + Kind: "while", + DataInputs: []DataEdge{{Name: "condition", SourceNode: cond}, {Name: "body", SourceNode: body}}, + ControlEdges: []NodeID{cond, body}, + }) + graph.EntryNode = graph.AddNode(&Node{Kind: "program", DataInputs: []DataEdge{{Name: "body", SourceNode: loop}}}) + + program, diags := LowerToBytecode(graph) + if len(diags) != 0 || program == nil { + t.Fatalf("LowerToBytecode() diags=%#v", diags) + } + if err := bytecode.ValidateProgram(program); err != nil { + t.Fatal(err) + } +} + +func TestLowerToBytecodeRejectsWhileWithoutControlEdges(t *testing.T) { + graph := NewGraph() + cond := graph.AddNode(&Node{Kind: "const", LiteralKind: "BOOL", Value: "false"}) + text := graph.AddNode(&Node{Kind: "const", LiteralKind: "STRING", Value: "no"}) + body := graph.AddNode(&Node{Kind: "print", DataInputs: []DataEdge{{Name: "value", SourceNode: text}}}) + loop := graph.AddNode(&Node{ + Kind: "while", + DataInputs: []DataEdge{{Name: "condition", SourceNode: cond}, {Name: "body", SourceNode: body}}, + }) + graph.EntryNode = graph.AddNode(&Node{Kind: "program", DataInputs: []DataEdge{{Name: "body", SourceNode: loop}}}) + + program, diags := LowerToBytecode(graph) + if program != nil || len(diags) != 1 || diags[0].Code != BytecodeUnsupportedCode || diags[0].RelatedNode != loop { + t.Fatalf("program=%v diags=%#v", program != nil, diags) + } + for _, relation := range DerivePhase1ControlRelations(graph) { + if relation.Controller == loop { + t.Fatalf("while without control edges produced %#v", relation) + } + } + + swapped := NewGraph() + cond = swapped.AddNode(&Node{Kind: "const", LiteralKind: "BOOL", Value: "false"}) + text = swapped.AddNode(&Node{Kind: "const", LiteralKind: "STRING", Value: "no"}) + body = swapped.AddNode(&Node{Kind: "print", DataInputs: []DataEdge{{Name: "value", SourceNode: text}}}) + swappedLoop := swapped.AddNode(&Node{ + Kind: "while", + DataInputs: []DataEdge{{Name: "condition", SourceNode: cond}, {Name: "body", SourceNode: body}}, + ControlEdges: []NodeID{body, cond}, + }) + swapped.EntryNode = swapped.AddNode(&Node{Kind: "program", DataInputs: []DataEdge{{Name: "body", SourceNode: swappedLoop}}}) + program, diags = LowerToBytecode(swapped) + if program != nil || len(diags) != 1 || diags[0].Code != BytecodeUnsupportedCode { + t.Fatalf("swapped program=%v diags=%#v", program != nil, diags) + } +} + +func TestVerifierRequiresWhileRoles(t *testing.T) { + graph := NewGraph() + graph.AddNode(&Node{Kind: "while"}) + var missing int + for _, diag := range NewVerifier(graph, TargetBytecode).Verify() { + if diag.Code == "HFIR_MISSING_ROLE" { + missing++ + } + } + if missing != 2 { + t.Fatalf("missing-role diagnostics = %d", missing) + } +} + +func lowerCheckedWhile(t *testing.T, source string) *Graph { + t.Helper() + root := parser.NewParser(lexer.NewLexer(source), "while.howl").ParseExpression() + checker.Check(root) + graph, err := LowerAST(root, "while.howl") + if err != nil { + t.Fatalf("LowerAST: %v", err) + } + if diags := NewVerifier(graph, TargetBytecode).Verify(); len(diags) != 0 { + t.Fatalf("Verify() = %#v", diags) + } + return graph +} diff --git a/internal/vm/hfir_equivalence_test.go b/internal/vm/hfir_equivalence_test.go index d99bffd..7a1770d 100644 --- a/internal/vm/hfir_equivalence_test.go +++ b/internal/vm/hfir_equivalence_test.go @@ -103,6 +103,44 @@ func TestHFIRBytecodeEquivalence(t *testing.T) { (return (* n (call fact (- n 1)))))) (print (call fact 5)))`, }, + { + name: "while counts", + source: `(cli_app + (let (n 0) + (while (< n 3) + (do + (set n (+ n 1)) + (print n)))))`, + }, + { + name: "while does not enter", + source: `(cli_app (while false (print "no")) (print "done"))`, + }, + { + name: "nested while", + source: `(cli_app + (let (i 0) + (while (< i 2) + (do + (let (j 0) + (while (< j 2) + (do + (print i j) + (set j (+ j 1))))) + (set i (+ i 1))))))`, + }, + { + name: "while inside defun", + source: `(cli_app + (defun steps (limit) + (type_hints (limit int) (return int)) + (let (n 0) + (do + (while (< n limit) + (set n (+ n 1))) + (return n)))) + (print (call steps 4)))`, + }, } t.Setenv("HFIR_EQ_TEST_VALUE", "expected") @@ -220,6 +258,36 @@ func runBytecodeOutcome(program *bytecode.BCProgram, stdin string, caps []capabi return outcome } +func TestHFIRBytecodeWhileRejectsNonBoolLikeAST(t *testing.T) { + // The checker rejects this before either compiler on the CLI. This + // comparison is the two bytecode emitters, which both still emit the + // while jumps and then fail in the VM. + source := `(cli_app (while 1 (print "no")))` + parsed := parser.NewParser(lexer.NewLexer(source), "hfir_equivalence.howl") + root := parsed.ParseExpression() + graph, err := hfir.LowerAST(root, "hfir_equivalence.howl") + if err != nil { + t.Fatal(err) + } + legacy := roundTripArtifact(t, bytecode.CompileToBytecode(root)) + direct, diagnostics := hfir.LowerToBytecode(graph) + if len(diagnostics) != 0 { + t.Fatalf("LowerToBytecode() diagnostics = %#v", diagnostics) + } + direct = roundTripArtifact(t, direct) + legacyOutcome := runBytecodeOutcome(legacy, "", nil) + directOutcome := runBytecodeOutcome(direct, "", nil) + if !reflect.DeepEqual(legacyOutcome, directOutcome) { + t.Fatalf("AST bytecode outcome = %#v\nHFIR bytecode outcome = %#v", legacyOutcome, directOutcome) + } + if legacyOutcome.vmError == nil || legacyOutcome.vmError.Code != "TYPE_ERROR" { + t.Fatalf("non-bool while = %#v, want TYPE_ERROR", legacyOutcome) + } + if legacyOutcome.stdout != "" || directOutcome.stdout != "" { + t.Fatalf("non-bool while printed stdout: AST %q HFIR %q", legacyOutcome.stdout, directOutcome.stdout) + } +} + func TestHFIRBytecodeCallArityRejectsLikeAST(t *testing.T) { // The checker rejects this arity before either compiler. This comparison // is the two bytecode emitters, which both still emit CALL. diff --git a/tests/conformance/abi_v1/10_while.howl b/tests/conformance/abi_v1/10_while.howl new file mode 100644 index 0000000..a8b531a --- /dev/null +++ b/tests/conformance/abi_v1/10_while.howl @@ -0,0 +1,7 @@ +(cli_app + (while false (print "no")) + (let (n 0) + (while (< n 3) + (do + (set n (+ n 1)) + (print n))))) diff --git a/tests/conformance/lowered_hfir_abi_v1.json b/tests/conformance/lowered_hfir_abi_v1.json index 4d9947b..d3aa6fa 100644 --- a/tests/conformance/lowered_hfir_abi_v1.json +++ b/tests/conformance/lowered_hfir_abi_v1.json @@ -134,6 +134,16 @@ "javascript_root": "web_app", "expect": "PASS", "stdout": "42\nphase3a" + }, + { + "name": "while_control", + "fixture": "tests/conformance/abi_v1/10_while.howl", + "deny_all": true, + "targets": ["hfir_bytecode", "bytecode", "interpreter", "go", "javascript"], + "javascript_root": "web_app", + "expect": "PASS", + "stdout": "1\n2\n3", + "forbid": "no" } ] }