diff --git a/change_log.md b/change_log.md index e62554c..acb2a0e 100644 --- a/change_log.md +++ b/change_log.md @@ -3,6 +3,7 @@ ## Unreleased ### Changed +* Experimental `-compile-hfir-bc` compiles and runs `if` with the existing `JUMP_IF_FALSE` and `JUMP` opcodes. An if node's control edges are the condition, the then-branch, and an optional else. Production `-compile-bc` is still AST bytecode after the gate. #90 stays Partial. Journal: `docs/journals/2026-09-30_lowered_hfir_abi_phase3c_if.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`. diff --git a/docs/hfir_execution_status.md b/docs/hfir_execution_status.md index 9058f18..59f1fda 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. 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. +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. Phase 3c teaches it `if` with those same jump opcodes, following control edges. `for` stays on its existing data-edge opcodes. The rest of the control-frame layout stays on the AST compiler. ## Phase-1 executable subset @@ -41,12 +41,13 @@ The direct lowerer supports a deterministic `cli_app` subset: | Capability evidence | `env`, using the existing shared capability authority | | 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. | +| Branches (Phase 3c) | `if`. Control edges are the condition, the then-branch, and an optional else. 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. 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. +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`. Phase 3c fills `ControlEdges` on `if` with the condition, the then-branch, and an optional else, and the lowerer follows them. An `if` without those edges, or with them swapped, returns `HFIR_BYTECODE_UNSUPPORTED` and no `BCProgram`. `for` and the 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 @@ -73,11 +74,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. Phase 3b covers `while` on that same flag. 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. Phase 3c covers `if` control edges 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`. 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. +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. Phase 3c adds source-level `if` control edges on the same flag. The model-adapter transport still rejects `defun` and `while`. `if` stays a Phase-1 transport kind, and the schema still has no control-edge field. 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) @@ -85,7 +86,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`). 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`. +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. Phase 3c fills `ControlEdges` for `if` on that flag. `for` stays empty. 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`, `docs/journals/2026-09-30_lowered_hfir_abi_phase3c_if.md`. ## Provenance limitation diff --git a/docs/hfir_failure_localization_status.md b/docs/hfir_failure_localization_status.md index bbed17d..5dc0794 100644 --- a/docs/hfir_failure_localization_status.md +++ b/docs/hfir_failure_localization_status.md @@ -16,13 +16,16 @@ and cannot be localized from that map. # Control-flow representation 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. +header: the condition, then the body. Phase 3c writes them on a +source-lowered `if`: the condition, the then-branch, and an optional else. +The experimental lowerer follows those successors. 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 only when those persisted successors match the +condition, then, and optional else, and a `while` only when its persisted +successors match the condition and body roles. The transport schema has no +control-edge field. 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_phase3c_if.md b/docs/journals/2026-09-30_lowered_hfir_abi_phase3c_if.md new file mode 100644 index 0000000..7c46bcc --- /dev/null +++ b/docs/journals/2026-09-30_lowered_hfir_abi_phase3c_if.md @@ -0,0 +1,38 @@ +# Lowered HFIR ABI, phase 3c: if and control edges on the experimental bytecode path + +## Why this slice + +Phase 3b made `while` executable on `-compile-hfir-bc` and filled `ControlEdges` on that header only. `if` already emitted `JUMP_IF_FALSE` and `JUMP` from data edges, and `LowerAST` still left those edges empty. The production hosts already run `if`. This slice records the control successors that jump layout follows, and fails closed when they are missing or swapped. + +This is not a CFG or SSA pass. `for` stays without control edges. #90 stays Partial. + +## What landed + +`LowerAST` gives `(if cond then)` and `(if cond then else)` named data edges and `ControlEdges` in that order: the test, the then-branch, and an optional else. `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 then-only `if` emits `JUMP_IF_FALSE` and no `JUMP`. An `if` whose control edges are missing, swapped, or not that sequence returns one `HFIR_BYTECODE_UNSUPPORTED` diagnostic and no `BCProgram`. There is no new opcode and no new capability. A `defun` body may contain `if`. `while` and `defun` keep the Phase 3a and Phase 3b behavior. `for` still lowers through its data edges and the existing `FOR_INIT` / `FOR_NEXT` opcodes, and its `ControlEdges` stay empty. + +The model-adapter transport still has no control-edge field. Decoding an `if` derives the successors from the validated roles, which is the same order `LowerAST` writes. A payload that names `control_edges` is rejected. Kind `while` and kind `defun` stay outside the transport allow-list, so #88 stays closed. Localization still does not treat a model-supplied control edge as authority. An `if` relation is published only when the persisted successors match the condition, then, and optional else. The published roles stay `then` and `else`. + +`tests/conformance/abi_v1/11_if.howl` is the shared fixture. `tools/difftest` runs it as `if_control`. The false branches must not print. The taken branches print `else`, `then`, `greater`, and `only`. + +| 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 then/else branch, a then-only branch, a nested branch, and a branch inside `defun`. + +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 and `if` nodes. The graph is not SSA, and the verifier still does not check cycles. +* `for` does 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. `for` is not packed into this slice. diff --git a/docs/reference/lowered_hfir_abi_v1.md b/docs/reference/lowered_hfir_abi_v1.md index c6bbd7e..dfb2f15 100644 --- a/docs/reference/lowered_hfir_abi_v1.md +++ b/docs/reference/lowered_hfir_abi_v1.md @@ -77,10 +77,18 @@ Those hosts must print the same stdout. A program the experimental lowerer rejec `(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`. +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. `for` still has 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`. +### Branches + +`(if cond then)` and `(if cond then else)` evaluate `cond` and run one branch. A false condition skips `then`. A condition that is not a bool is `TYPE_ERROR` at runtime, and the checker rejects a known non-bool before either compiler. + +Phase 3c records that shape on the experimental lowerer. `LowerAST` stores a `condition` data edge, a `then` data edge, an optional `else` data edge, and `ControlEdges` in that order. `LowerToBytecode` follows those control edges and emits the existing `JUMP_IF_FALSE` and, when an else is present, `JUMP`, with the same relative offsets as the AST bytecode compiler. An `if` whose control edges are missing or swapped fails with `HFIR_BYTECODE_UNSUPPORTED` and no `BCProgram`. It does not add an opcode. `for` still has empty `ControlEdges`. The model-adapter transport still accepts `if` and still rejects `while` and `defun`. The transport schema has no control-edge field; decoding an `if` derives the successors from those roles. + +The conformance case `if_control` is `tests/conformance/abi_v1/11_if.howl`. Its hosts are the same five as `defun_call`. The false branches must not print. The taken branches print `else`, `then`, `greater`, and `only`. + ### Memory and runtime imports v1 defines no linear memory and no Wasm import table. @@ -141,15 +149,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. 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. +`hfir.LowerAST` is not that form. It fills data edges for the semantic subset. Phase 3b fills `ControlEdges` on a `while` header: the condition, then the body. Phase 3c fills `ControlEdges` on an `if`: the condition, the then-branch, and an optional else. The v1 arithmetic fixture has no `while`. Its `if` nodes carry those branch successors, and every other node in that fixture still has empty `ControlEdges`. `for` stays 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 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. +* Production `-compile-bc` still compiles the AST. Flipping that path is still Phase 2. Phase 3a, Phase 3b, and Phase 3c 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. `if` on that flag follows control edges for the condition, the then-branch, and an optional else (Phase 3c). The interpreter, the production bytecode VM, Go, and JavaScript still run calls, loops, and branches 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`. Phase 3c fills them for `if`. `for` stays empty. * 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. @@ -159,4 +167,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`, 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. +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`, Phase 3b `while`, and Phase 3c `if`. That test is not the production compiler. The `defun_call`, `while_control`, and `if_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 3a55759..1ed372e 100644 --- a/howlframe.go +++ b/howlframe.go @@ -491,8 +491,9 @@ var hfirBlockingCodes = map[string]bool{ // 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. LowerAST fills -// ControlEdges for a while header only (condition, then body). That is not -// a CFG, and isFeasible only has rules for "wasm". +// ControlEdges for a while header (condition, then body) and for an if +// (condition, then, optional else). for stays empty. 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 afba397..ca304db 100644 --- a/howlframe_test.go +++ b/howlframe_test.go @@ -1301,6 +1301,70 @@ func TestCompileBcStaysASTWhileHfirBcRunsWhile(t *testing.T) { } } +// TestCompileBcStaysASTWhileHfirBcRunsIf locks the production flag for the +// branch slice. Both flags compile and run a small if to the same stdout. +// -compile-bc is still the AST compiler: sleep is in that compiler and still +// outside the experimental lowerer. Phase 3c does not flip -compile-bc. +func TestCompileBcStaysASTWhileHfirBcRunsIf(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) + } + + dir := t.TempDir() + source := filepath.Join(dir, "if.howl") + if err := os.WriteFile(source, []byte("(cli_app (if false (print \"no\") (print \"else\")) (if true (print \"then\")))\n"), 0o644); err != nil { + t.Fatal(err) + } + + astOut := filepath.Join(dir, "ast.bc.bin") + if output, err := exec.Command(howlframeBinary, "-compile-bc", source, "-o", astOut).CombinedOutput(); err != nil { + t.Fatalf("-compile-bc rejected if: %v\n%s", err, output) + } + runOut, err := exec.Command(howlframeBinary, "-run-bc", astOut).CombinedOutput() + if err != nil { + t.Fatalf("-run-bc of production if artifact: %v\n%s", err, runOut) + } + if strings.TrimSpace(string(runOut)) != "else\nthen" { + t.Fatalf("production if stdout = %q", runOut) + } + if strings.Contains(string(runOut), "no") { + t.Fatalf("production if printed the false branch: %q", runOut) + } + + hfirOut := filepath.Join(dir, "hfir.bc.bin") + if output, err := exec.Command(howlframeBinary, "-compile-hfir-bc", source, "-o", hfirOut).CombinedOutput(); err != nil { + t.Fatalf("-compile-hfir-bc rejected if: %v\n%s", err, output) + } + hfirRun, err := exec.Command(howlframeBinary, "-run-bc", hfirOut).CombinedOutput() + if err != nil { + t.Fatalf("-run-bc of experimental if artifact: %v\n%s", err, hfirRun) + } + if string(hfirRun) != string(runOut) { + t.Fatalf("experimental if 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 sleep:\n%s", output) + } + if !strings.Contains(string(output), "sleep") { + t.Fatalf("experimental rejection = %s", output) + } + if _, statErr := os.Stat(rejected); !os.IsNotExist(statErr) { + t.Fatalf("experimental rejection wrote an artifact: %v", statErr) + } +} + // TestCompileBcFailsClosedCitingOwningTracker proves the diagnostic points at // the backlog item that owns the gap, so the failure is actionable rather than // just a wall. diff --git a/improvements.md b/improvements.md index cee7f58..0a8c40d 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. 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`. | +| 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. Phase 3c runs `if` on that path and fills control edges for the condition, the then-branch, and an optional else. `for` stays empty. #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`, and `docs/journals/2026-09-30_lowered_hfir_abi_phase3c_if.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. 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`. +* **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. Phase 3c: that flag lowers `if` onto those same opcodes, and `ControlEdges` on an if are the condition, the then-branch, and an optional else. `for` and the other nodes still have empty control edges. The model-adapter transport still rejects `defun` and `while`. `if` stays accepted, and the transport schema still has no control-edge field. 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`, and `docs/journals/2026-09-30_lowered_hfir_abi_phase3c_if.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. 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. +* **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. Phase 3c fills `ControlEdges` for `if`. `for` stays empty. 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. Phase 3c runs `if` on that flag by following control edges, and does not fill control edges for `for`. 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. 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`. +* **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. Phase 3c lowers `if` control edges on the same path and does not pack `for`. `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`, `docs/journals/2026-09-30_lowered_hfir_abi_phase3b_while.md`, and `docs/journals/2026-09-30_lowered_hfir_abi_phase3c_if.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 0d423b4..41c4c38 100644 --- a/internal/hfir/abi_v1_test.go +++ b/internal/hfir/abi_v1_test.go @@ -63,15 +63,31 @@ func TestLoweredABIV1WasmRejectionSetIsClosed(t *testing.T) { } } -func TestLoweredABIV1CoreFixtureHasNoControlEdges(t *testing.T) { +func TestLoweredABIV1ArithFixtureControlEdgesAreIfOnly(t *testing.T) { graph := lowerFixture(t, filepath.Join("..", "..", "tests", "conformance", "abi_v1", "01_arith_if.howl")) if len(graph.Nodes) == 0 { t.Fatal("arith fixture lowered no nodes") } + var branches int for _, node := range graph.Nodes { - if len(node.ControlEdges) != 0 { - t.Fatalf("node %s kind %s has control edges %v; the arith fixture has no while", node.ID, node.Kind, node.ControlEdges) + if node.Kind != "if" { + if len(node.ControlEdges) != 0 { + t.Fatalf("node %s kind %s has control edges %v; the arith fixture fills them on if only", node.ID, node.Kind, node.ControlEdges) + } + continue + } + branches++ + if len(node.ControlEdges) != 3 || len(node.DataInputs) != 3 || node.DataInputs[0].Name != "condition" || node.DataInputs[1].Name != "then" || node.DataInputs[2].Name != "else" { + t.Fatalf("if shape = %#v", node) } + for index := range node.ControlEdges { + if node.ControlEdges[index] != node.DataInputs[index].SourceNode { + t.Fatalf("if control edges = %v, data = %#v", node.ControlEdges, node.DataInputs) + } + } + } + if branches != 2 { + t.Fatalf("if nodes = %d", branches) } program, diags := LowerToBytecode(graph) if len(diags) != 0 { diff --git a/internal/hfir/bytecode.go b/internal/hfir/bytecode.go index 45652b1..a5bb0d6 100644 --- a/internal/hfir/bytecode.go +++ b/internal/hfir/bytecode.go @@ -171,23 +171,26 @@ func (c *bytecodeLowerer) compile(node *Node) (instructions []bytecode.BCInstruc } return append(insts, instruction(bytecode.OpSetVar, "SET_VAR", func(inst *bytecode.BCInstruction) { inst.StringOperand = node.Value })), nil case "if": - if len(children) != 2 && len(children) != 3 || node.DataInputs[0].Name != "condition" || node.DataInputs[1].Name != "then" || len(children) == 3 && node.DataInputs[2].Name != "else" { - diagnostic := c.diagnostic(node, "if requires condition, then, and optional else") - return nil, &diagnostic + successors, shapeDiagnostic := c.ifSuccessors(node) + if shapeDiagnostic != nil { + return nil, shapeDiagnostic } - condition, childDiagnostic := compileChild(0) + condition, childDiagnostic := c.compile(successors[0]) if childDiagnostic != nil { return nil, childDiagnostic } - thenInsts, childDiagnostic := compileChild(1) + thenInsts, childDiagnostic := c.compile(successors[1]) if childDiagnostic != nil { return nil, childDiagnostic } - if len(children) == 2 { + // Same relative jumps as bytecode.CompileToBytecode's if case. + // JUMP_IF_FALSE skips the then-branch. A present else is skipped + // by JUMP. No new opcode. + if len(successors) == 2 { condition = append(condition, instruction(bytecode.OpJumpIfFalse, "JUMP_IF_FALSE", func(inst *bytecode.BCInstruction) { inst.IntOperand = int64(len(thenInsts) + 1) })) return append(condition, thenInsts...), nil } - elseInsts, childDiagnostic := compileChild(2) + elseInsts, childDiagnostic := c.compile(successors[2]) if childDiagnostic != nil { return nil, childDiagnostic } @@ -580,6 +583,36 @@ func (c *bytecodeLowerer) whileSuccessors(node *Node) (*Node, *Node, *Diagnostic return cond, body, nil } +// ifSuccessors is the executable control-edge contract for a branch. +// ControlEdges are the condition, then the then-branch, and an optional +// else. They must be the same nodes as the named data edges, in that order. +// An if whose control edges are missing or swapped is not executable. +func (c *bytecodeLowerer) ifSuccessors(node *Node) ([]*Node, *Diagnostic) { + n := len(node.DataInputs) + shapeOK := (n == 2 || n == 3) && len(node.ControlEdges) == n && node.DataInputs[0].Name == "condition" && node.DataInputs[1].Name == "then" && (n != 3 || node.DataInputs[2].Name == "else") + if shapeOK { + for index := range node.ControlEdges { + if node.ControlEdges[index] != node.DataInputs[index].SourceNode { + shapeOK = false + break + } + } + } + if !shapeOK { + diagnostic := c.diagnostic(node, "if requires a condition control edge, a then control edge, and an optional else control edge") + return nil, &diagnostic + } + successors := make([]*Node, n) + for index, id := range node.ControlEdges { + successors[index] = c.graph.NodeByID(id) + if successors[index] == nil { + diagnostic := c.diagnostic(node, "if control edge references a missing node") + return nil, &diagnostic + } + } + return successors, 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/if_test.go b/internal/hfir/if_test.go new file mode 100644 index 0000000..ec7dd25 --- /dev/null +++ b/internal/hfir/if_test.go @@ -0,0 +1,273 @@ +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 TestLowerToBytecodeEmitsIfControlEdges(t *testing.T) { + graph := lowerCheckedIf(t, `(cli_app + (if false + (print "no") + (print "else")) + (if true + (print "then")) + (for item (list "a") + (print item)))`) + var withElse, withoutElse, loop *Node + for _, node := range graph.Nodes { + switch node.Kind { + case "if": + if len(node.DataInputs) == 3 { + if withElse != nil { + t.Fatal("expected one if with else") + } + withElse = node + } else { + if withoutElse != nil { + t.Fatal("expected one if without else") + } + withoutElse = node + } + case "for": + loop = node + case "while", "defun": + t.Fatalf("unexpected %s", node.Kind) + } + } + if withElse == nil || withoutElse == nil || loop == nil { + t.Fatalf("withElse=%v withoutElse=%v for=%v", withElse != nil, withoutElse != nil, loop != nil) + } + if len(withElse.ControlEdges) != 3 || withElse.DataInputs[0].Name != "condition" || withElse.DataInputs[1].Name != "then" || withElse.DataInputs[2].Name != "else" { + t.Fatalf("if else = %#v", withElse) + } + for index := range withElse.ControlEdges { + if withElse.ControlEdges[index] != withElse.DataInputs[index].SourceNode { + t.Fatalf("control %v data %#v", withElse.ControlEdges, withElse.DataInputs) + } + } + if len(withoutElse.ControlEdges) != 2 || withoutElse.DataInputs[0].Name != "condition" || withoutElse.DataInputs[1].Name != "then" { + t.Fatalf("if then = %#v", withoutElse) + } + if len(loop.ControlEdges) != 0 { + t.Fatalf("for has control edges %v", loop.ControlEdges) + } + thenBranch := graph.NodeByID(withElse.ControlEdges[1]) + elseBranch := graph.NodeByID(withElse.ControlEdges[2]) + if thenBranch == nil || thenBranch.Kind != "print" || elseBranch == nil || elseBranch.Kind != "print" { + t.Fatalf("successors then=%#v else=%#v", thenBranch, elseBranch) + } + relations := DerivePhase1ControlRelations(graph) + var sawThen, sawElse, sawCond bool + for _, relation := range relations { + if relation.Controller != withElse.ID { + continue + } + switch relation.Role { + case "then": + sawThen = relation.Controlled == withElse.ControlEdges[1] && relation.Ordinal == 1 + case "else": + sawElse = relation.Controlled == withElse.ControlEdges[2] && relation.Ordinal == 2 + case "condition": + sawCond = true + } + } + if !sawThen || !sawElse || sawCond { + t.Fatalf("if 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) || !hasOp(program.Main, bytecode.OpForInit) { + t.Fatalf("main = %#v", program.Main) + } + if err := bytecode.ValidateProgram(program); err != nil { + t.Fatal(err) + } +} + +func TestLowerToBytecodeIfInsideDefun(t *testing.T) { + graph := lowerCheckedIf(t, `(cli_app + (defun choose (flag) + (type_hints (flag bool) (return string)) + (if flag + (return "yes") + (return "no"))) + (print (call choose true)))`) + var branch *Node + for _, node := range graph.Nodes { + if node.Kind == "if" { + branch = node + } + if node.Kind == "defun" && len(node.ControlEdges) != 0 { + t.Fatalf("defun has control edges %v", node.ControlEdges) + } + if node.Kind == "while" { + t.Fatal("unexpected while") + } + } + if branch == nil || len(branch.ControlEdges) != 3 { + t.Fatalf("if = %#v", branch) + } + program, diags := LowerToBytecode(graph) + if len(diags) != 0 || program == nil || program.Functions["choose"] == nil { + t.Fatalf("program=%v diags=%#v", program != nil, diags) + } + fn := program.Functions["choose"].Instructions + if !hasOp(fn, bytecode.OpJumpIfFalse) || !hasOp(fn, bytecode.OpJump) || !hasOp(fn, bytecode.OpReturn) { + t.Fatalf("function = %#v", fn) + } +} + +func TestLowerToBytecodeIfFromGraphWithoutAST(t *testing.T) { + graph := NewGraph() + cond := graph.AddNode(&Node{Kind: "const", LiteralKind: "BOOL", Value: "true"}) + yesText := graph.AddNode(&Node{Kind: "const", LiteralKind: "STRING", Value: "yes"}) + noText := graph.AddNode(&Node{Kind: "const", LiteralKind: "STRING", Value: "no"}) + thenBranch := graph.AddNode(&Node{Kind: "print", DataInputs: []DataEdge{{Name: "value", SourceNode: yesText}}}) + elseBranch := graph.AddNode(&Node{Kind: "print", DataInputs: []DataEdge{{Name: "value", SourceNode: noText}}}) + branch := graph.AddNode(&Node{ + Kind: "if", + DataInputs: []DataEdge{ + {Name: "condition", SourceNode: cond}, + {Name: "then", SourceNode: thenBranch}, + {Name: "else", SourceNode: elseBranch}, + }, + ControlEdges: []NodeID{cond, thenBranch, elseBranch}, + }) + graph.EntryNode = graph.AddNode(&Node{Kind: "program", DataInputs: []DataEdge{{Name: "body", SourceNode: branch}}}) + + 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) + } + + onlyThen := NewGraph() + cond = onlyThen.AddNode(&Node{Kind: "const", LiteralKind: "BOOL", Value: "false"}) + text := onlyThen.AddNode(&Node{Kind: "const", LiteralKind: "STRING", Value: "no"}) + body := onlyThen.AddNode(&Node{Kind: "print", DataInputs: []DataEdge{{Name: "value", SourceNode: text}}}) + arm := onlyThen.AddNode(&Node{ + Kind: "if", + DataInputs: []DataEdge{{Name: "condition", SourceNode: cond}, {Name: "then", SourceNode: body}}, + ControlEdges: []NodeID{cond, body}, + }) + onlyThen.EntryNode = onlyThen.AddNode(&Node{Kind: "program", DataInputs: []DataEdge{{Name: "body", SourceNode: arm}}}) + program, diags = LowerToBytecode(onlyThen) + if len(diags) != 0 || program == nil { + t.Fatalf("then-only diags=%#v", diags) + } + if hasOp(program.Main, bytecode.OpJump) { + t.Fatalf("then-only if emitted JUMP: %#v", program.Main) + } + if !hasOp(program.Main, bytecode.OpJumpIfFalse) { + t.Fatalf("then-only if = %#v", program.Main) + } +} + +func TestLowerToBytecodeRejectsIfWithoutControlEdges(t *testing.T) { + graph := NewGraph() + cond := graph.AddNode(&Node{Kind: "const", LiteralKind: "BOOL", Value: "true"}) + yesText := graph.AddNode(&Node{Kind: "const", LiteralKind: "STRING", Value: "yes"}) + noText := graph.AddNode(&Node{Kind: "const", LiteralKind: "STRING", Value: "no"}) + thenBranch := graph.AddNode(&Node{Kind: "print", DataInputs: []DataEdge{{Name: "value", SourceNode: yesText}}}) + elseBranch := graph.AddNode(&Node{Kind: "print", DataInputs: []DataEdge{{Name: "value", SourceNode: noText}}}) + branch := graph.AddNode(&Node{ + Kind: "if", + DataInputs: []DataEdge{ + {Name: "condition", SourceNode: cond}, + {Name: "then", SourceNode: thenBranch}, + {Name: "else", SourceNode: elseBranch}, + }, + }) + graph.EntryNode = graph.AddNode(&Node{Kind: "program", DataInputs: []DataEdge{{Name: "body", SourceNode: branch}}}) + + program, diags := LowerToBytecode(graph) + if program != nil || len(diags) != 1 || diags[0].Code != BytecodeUnsupportedCode || diags[0].RelatedNode != branch { + t.Fatalf("program=%v diags=%#v", program != nil, diags) + } + for _, relation := range DerivePhase1ControlRelations(graph) { + if relation.Controller == branch { + t.Fatalf("if without control edges produced %#v", relation) + } + } + + swapped := NewGraph() + cond = swapped.AddNode(&Node{Kind: "const", LiteralKind: "BOOL", Value: "true"}) + yesText = swapped.AddNode(&Node{Kind: "const", LiteralKind: "STRING", Value: "yes"}) + noText = swapped.AddNode(&Node{Kind: "const", LiteralKind: "STRING", Value: "no"}) + thenBranch = swapped.AddNode(&Node{Kind: "print", DataInputs: []DataEdge{{Name: "value", SourceNode: yesText}}}) + elseBranch = swapped.AddNode(&Node{Kind: "print", DataInputs: []DataEdge{{Name: "value", SourceNode: noText}}}) + swappedBranch := swapped.AddNode(&Node{ + Kind: "if", + DataInputs: []DataEdge{ + {Name: "condition", SourceNode: cond}, + {Name: "then", SourceNode: thenBranch}, + {Name: "else", SourceNode: elseBranch}, + }, + ControlEdges: []NodeID{cond, elseBranch, thenBranch}, + }) + swapped.EntryNode = swapped.AddNode(&Node{Kind: "program", DataInputs: []DataEdge{{Name: "body", SourceNode: swappedBranch}}}) + program, diags = LowerToBytecode(swapped) + if program != nil || len(diags) != 1 || diags[0].Code != BytecodeUnsupportedCode || diags[0].RelatedNode != swappedBranch { + t.Fatalf("swapped program=%v diags=%#v", program != nil, diags) + } + for _, relation := range DerivePhase1ControlRelations(swapped) { + if relation.Controller == swappedBranch { + t.Fatalf("swapped if produced %#v", relation) + } + } +} + +func TestModelAdapterIfDerivesControlEdgesFromRoles(t *testing.T) { + candidate := mustCandidate(t, candidateTransport{SchemaVersion: ModelAdapterSchemaVersion, GraphVersion: "v1", EntryNode: "program", Nodes: []transportNode{ + testNode("program", "program", "", "", []transportEdge{{Role: "body", NodeID: "branch"}}), + testNode("condition", "const", "true", "BOOL", nil), + testNode("yes", "const", "yes", "STRING", nil), + testNode("no", "const", "no", "STRING", nil), + testNode("then", "print", "", "", []transportEdge{{Role: "value", NodeID: "yes"}}), + testNode("else", "print", "", "", []transportEdge{{Role: "value", NodeID: "no"}}), + testNode("branch", "if", "", "", []transportEdge{{Role: "condition", NodeID: "condition"}, {Role: "then", NodeID: "then"}, {Role: "else", NodeID: "else"}}), + }}) + branch := candidate.Graph.NodeByID("branch") + if branch == nil || len(branch.ControlEdges) != 3 || branch.ControlEdges[0] != "condition" || branch.ControlEdges[1] != "then" || branch.ControlEdges[2] != "else" { + t.Fatalf("branch = %#v", branch) + } + program, diags := CompileCandidate(candidate) + if len(diags) != 0 || program == nil { + t.Fatalf("CompileCandidate() diags=%#v", diags) + } + if !hasOp(program.Main, bytecode.OpJumpIfFalse) || !hasOp(program.Main, bytecode.OpJump) { + t.Fatalf("main = %#v", program.Main) + } +} + +func TestModelTransportRejectsControlEdgeField(t *testing.T) { + payload := []byte(`{"schema_version":"hfir-model-adapter/v1","graph_version":"v1","entry_node":"program","nodes":[{"id":"program","kind":"program","value":"","inputs":[],"provenance":{"label":"t"},"control_edges":["program"]}]}`) + candidate, diags := DecodeCandidate(payload) + if candidate.Graph != nil || len(diags) != 1 || diags[0].Code != "HFIR_TRANSPORT_INVALID" { + t.Fatalf("candidate=%v diags=%#v", candidate.Graph != nil, diags) + } +} + +func lowerCheckedIf(t *testing.T, source string) *Graph { + t.Helper() + root := parser.NewParser(lexer.NewLexer(source), "if.howl").ParseExpression() + checker.Check(root) + graph, err := LowerAST(root, "if.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/hfir/localization.go b/internal/hfir/localization.go index 5547d64..b614e2a 100644 --- a/internal/hfir/localization.go +++ b/internal/hfir/localization.go @@ -102,12 +102,15 @@ func DerivePhase1ControlRelations(graph *Graph) []DerivedControlRelation { relations = append(relations, DerivedControlRelation{Controller: node.ID, Controlled: node.DataInputs[1].SourceNode, Role: "body", Ordinal: 1}) } case "if": - if len(node.DataInputs) >= 2 && len(node.DataInputs) <= 3 && node.DataInputs[0].Name == "condition" { - for index := 1; index < len(node.DataInputs); index++ { - input := node.DataInputs[index] - if (input.Name == "then" || input.Name == "else") && graph.NodeByID(input.SourceNode) != nil { - relations = append(relations, DerivedControlRelation{Controller: node.ID, Controlled: input.SourceNode, Role: input.Name, Ordinal: index}) - } + // The relation exists only when the persisted control successors + // are the condition, then the then-branch, and an optional else. + // An if with data roles and no control edges yields nothing. + // The published roles stay then and else. The condition is the + // test the lowerer jumps on, not a contained branch. + if ifControlSuccessorsMatch(graph, node) { + for index := 1; index < len(node.ControlEdges); index++ { + role := node.DataInputs[index].Name + relations = append(relations, DerivedControlRelation{Controller: node.ID, Controlled: node.ControlEdges[index], Role: role, Ordinal: index}) } } case "while": @@ -131,6 +134,25 @@ func DerivePhase1ControlRelations(graph *Graph) []DerivedControlRelation { return relations } +// ifControlSuccessorsMatch reports whether an if node's persisted control +// successors are the condition, the then-branch, and an optional else, in +// that order, and those nodes exist. A missing or swapped edge matches nothing. +func ifControlSuccessorsMatch(graph *Graph, node *Node) bool { + if graph == nil || node == nil { + return false + } + n := len(node.DataInputs) + if (n != 2 && n != 3) || len(node.ControlEdges) != n || node.DataInputs[0].Name != "condition" || node.DataInputs[1].Name != "then" || (n == 3 && node.DataInputs[2].Name != "else") { + return false + } + for index := range node.ControlEdges { + if node.ControlEdges[index] != node.DataInputs[index].SourceNode || graph.NodeByID(node.ControlEdges[index]) == nil { + return false + } + } + return true +} + // LocalizeFailure derives a bounded repair region from trusted evidence. It // never accepts model-authored node IDs and fails closed on stale evidence, // untrusted runtime provenance, authority denials, or an oversized region. diff --git a/internal/hfir/lowering.go b/internal/hfir/lowering.go index 20c0e86..7c6feb2 100644 --- a/internal/hfir/lowering.go +++ b/internal/hfir/lowering.go @@ -164,10 +164,14 @@ func (ctx *LoweringContext) lowerSemanticList(node *Node, astNode *ast.Node, hea }{{"value", astNode.Children[2]}}) return id, true, err case "if": + // (if cond then) or (if cond then else). ControlEdges are the + // branch successors: the test, then the then-branch, then an + // optional else. for stays without control edges. if len(astNode.Children) != 3 && len(astNode.Children) != 4 { return "", false, nil } node.Kind = "if" + id := ctx.Graph.AddNode(node) parts := []struct { name string child *ast.Node @@ -178,8 +182,17 @@ func (ctx *LoweringContext) lowerSemanticList(node *Node, astNode *ast.Node, hea child *ast.Node }{"else", astNode.Children[3]}) } - id, err := addNamed(parts) - return id, true, err + edges := make([]NodeID, 0, len(parts)) + for _, part := range parts { + childID, err := ctx.lowerNode(part.child) + if err != nil { + return "", true, err + } + node.DataInputs = append(node.DataInputs, DataEdge{Name: part.name, SourceNode: childID}) + edges = append(edges, childID) + } + node.ControlEdges = edges + return id, true, nil case "+", "-", "*", "/", "<", ">", "<=", ">=", "==", "!=", "=", "and", "or": if len(astNode.Children) != 3 { return "", false, nil diff --git a/internal/hfir/model_adapter.go b/internal/hfir/model_adapter.go index b3ee454..5b24ee6 100644 --- a/internal/hfir/model_adapter.go +++ b/internal/hfir/model_adapter.go @@ -722,6 +722,8 @@ func nodeRoles(kind string) []string { // 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. + // Phase 3c fills if control edges from the roles above. The + // transport still has no control-edge field. // This allow-list stays the Phase-1 adapter subset (#88). return nil } @@ -892,7 +894,18 @@ func nodeFromTransport(node transportNode) *Node { for _, input := range node.Inputs { inputs = append(inputs, DataEdge{Name: input.Role, SourceNode: input.NodeID}) } - return &Node{ID: node.ID, Kind: node.Kind, Value: node.Value, LiteralKind: node.LiteralKind, DataInputs: inputs, Provenance: Provenance{Filename: "model:" + node.Provenance.Label}} + result := &Node{ID: node.ID, Kind: node.Kind, Value: node.Value, LiteralKind: node.LiteralKind, DataInputs: inputs, Provenance: Provenance{Filename: "model:" + node.Provenance.Label}} + // The transport schema has no control-edge field, so a model cannot + // name a successor. An if's executable edges are the validated roles + // in order, the same contract LowerAST writes from source. while and + // defun are not transport kinds. + if node.Kind == "if" { + result.ControlEdges = make([]NodeID, len(node.Inputs)) + for index, input := range node.Inputs { + result.ControlEdges[index] = input.NodeID + } + } + return result } func transportFromGraph(graph *Graph) candidateTransport { diff --git a/internal/hfir/while_test.go b/internal/hfir/while_test.go index 0936cca..2920654 100644 --- a/internal/hfir/while_test.go +++ b/internal/hfir/while_test.go @@ -27,8 +27,19 @@ func TestLowerToBytecodeEmitsWhileControlEdges(t *testing.T) { 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 node.Kind == "if" { + if len(node.ControlEdges) != 3 || len(node.DataInputs) != 3 { + t.Fatalf("if %s = %#v", node.ID, node) + } + for index := range node.ControlEdges { + if node.ControlEdges[index] != node.DataInputs[index].SourceNode { + t.Fatalf("if control %v data %#v", node.ControlEdges, node.DataInputs) + } + } + continue + } + if node.Kind == "for" && len(node.ControlEdges) != 0 { + t.Fatalf("for %s has control edges %v", node.ID, node.ControlEdges) } } if loop == nil { diff --git a/internal/vm/hfir_equivalence_test.go b/internal/vm/hfir_equivalence_test.go index 7a1770d..42141c3 100644 --- a/internal/vm/hfir_equivalence_test.go +++ b/internal/vm/hfir_equivalence_test.go @@ -141,6 +141,45 @@ func TestHFIRBytecodeEquivalence(t *testing.T) { (return n)))) (print (call steps 4)))`, }, + { + name: "if then else", + source: `(cli_app + (if false + (print "no") + (print "else")) + (if true + (print "then") + (print "no")) + (if (> 2 1) + (print "greater") + (print "no")))`, + }, + { + name: "if without else", + source: `(cli_app + (if false (print "no")) + (if true (print "only")))`, + }, + { + name: "nested if", + source: `(cli_app + (if true + (if false + (print "no") + (print "inner")) + (print "no")))`, + }, + { + name: "if inside defun", + source: `(cli_app + (defun choose (flag) + (type_hints (flag bool) (return string)) + (if flag + (return "yes") + (return "no"))) + (print (call choose true)) + (print (call choose false)))`, + }, } t.Setenv("HFIR_EQ_TEST_VALUE", "expected") diff --git a/tests/conformance/abi_v1/11_if.howl b/tests/conformance/abi_v1/11_if.howl new file mode 100644 index 0000000..1b62bce --- /dev/null +++ b/tests/conformance/abi_v1/11_if.howl @@ -0,0 +1,14 @@ +(cli_app + (if false + (print "no") + (print "else")) + (if true + (print "then") + (print "no")) + (if (> 2 1) + (print "greater") + (print "no")) + (if false + (print "no")) + (if true + (print "only"))) diff --git a/tests/conformance/lowered_hfir_abi_v1.json b/tests/conformance/lowered_hfir_abi_v1.json index d3aa6fa..81e4146 100644 --- a/tests/conformance/lowered_hfir_abi_v1.json +++ b/tests/conformance/lowered_hfir_abi_v1.json @@ -144,6 +144,16 @@ "expect": "PASS", "stdout": "1\n2\n3", "forbid": "no" + }, + { + "name": "if_control", + "fixture": "tests/conformance/abi_v1/11_if.howl", + "deny_all": true, + "targets": ["hfir_bytecode", "bytecode", "interpreter", "go", "javascript"], + "javascript_root": "web_app", + "expect": "PASS", + "stdout": "else\nthen\ngreater\nonly", + "forbid": "no" } ] }