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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions docs/journals/2026-10-12_issue141_arity_target_sweep.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# 2026-10-12 — Issue #141: table-driven arity check and Go/JS target sweep

## What changed

### 1. One arity table, enforced before codegen
- `internal/construct/arity.go` declares an `Arity{Min,Max}` for every
construct-registry entry. `Variadic` means there is no maximum. 136 entries
have an arity and 18 are exempt, out of 154 registry entries.
- Each exemption carries a reason. Most are Unsupported constructs or
directives (`include`, `use`, `patch`, `with_context`) that earlier passes
consume. The rest are pure type annotations (`type_hint`, `type_hints`,
`type_param`).
- `internal/construct/arity_test.go` is the drift guard. It fails if a
registry entry has neither an arity nor an exemption. It also fails on
stale keys, malformed ranges, or an entry that has both.
- `construct.EnforceArity` runs in `howlframe.go` immediately before each of
the five `ast.Desugar` call sites. That covers every pipeline: bytecode,
HFIR bytecode, the interpreter (`-run`), `check`, and Go/JS builds.
- A wrong arity reports
`ARITY_ERROR: read_file expects 1 arg, got 0`
(or `at least N args`, or `N to M args`).
- Exit code is 1, with no panic and no artifact, under both the default
and governed profiles.
- The walk is context-aware. It skips let binding lists, defun and lambda
parameter lists, dict pairs, and circuit argument lists, and it handles
`switch` and `cond` clauses. So `(let (min 0 max 3) ...)` is not treated as
a call to `min`.
- The AST bytecode compiler keeps the same table as a backstop at the top of
`compileNode`. The #139 fetch-specific check moved onto the table:
`fetch` is `{2,3}`.
- `and`/`or` are `{2, Variadic}` because desugar already folds the n-ary
form. The bytecode `and`/`or` case now fails closed on an unfolded n-ary
node. Before this change it silently dropped every operand after the
second.

### 2. Tests
- `TestIssue141MotokoArityRepros` covers the full repro list from the issue,
plus `fetch` with 1 arg.
- `TestIssue141EveryArityEntry` runs one too-few call and one too-many call
for every table entry (where those exist) under both profiles. Each must
give exit 1, an ARITY_ERROR, no panic and no artifact. Under the governed
profile, a PROFILE_FORBIDDEN_CONSTRUCT rejection is also accepted.
- `TestIssue141ArityFuzzNoPanic` tries arities 0..max(4, Min+1) for all 154
registry heads, about 4.3k CLI runs. The build paths are default bytecode,
governed bytecode, `-compile-hfir-bc`, `--target=go` and `--target=js`.
`-run` is used only at rejected arities, so no program executes and there
is no network, LLM or exec.
- The fuzz test found panics, all now fixed:
- Empty `(export)` and `(module)` panicked in the checker on every path.
Both now have an arity of 1+.
- On `--target=go`, `trace`, `fuzzy_cast`, `semantic_match`,
`assert_semantic`, `web_app` with no port, and `web_app` with non-list
handlers all panicked. Their `ast.ReportError` shape checks had been
commented out. They are re-enabled, and the `semantic_match` case check
now compares against the real node type `"List"`.

### 3. Go/JS: never a false "Built"
- `rejectUnsupportedTargetValueForms` (in `howlframe.go`, after desugar)
fails closed on any `let`, `do`, `if`, `for` or `while` used as a value on
`--target=go|js`, with `UNSUPPORTED_ON_TARGET: <form> used as a value ...`.
- These backends emit those forms as statements, so the output did not
compile.
- Sugar that lowers to this shape is now rejected instead of printing
"Built": all/any, ver_gt/ver_ge, not, starts_with/ends_with,
list_contains, map_has, cond as a value, a value `if`, and
list_get/parse_json on a computed operand.
- `time_now` used inside an expression on `--target=go` (for example via
`unix_now`, i.e. `(to_string (time_now))`) emitted an empty expression.
It now emits `int(time.Now().Unix())`. `(print (time_now))` used to compile
to `fmt.Println()` and print an empty line; it now prints Unix seconds.
- `TestIssue141TargetNeverFalseBuilt` builds 31 construct probes for each
target. Each build must either be rejected or produce output that passes
`go vet` (with a replace-module) or `node --check`.

## Behavior changes
- Programs with a wrong arity that used to build, sometimes silently wrong,
now fail with ARITY_ERROR. `check` also reports it.
- The test `TestCLIRequiredCapsAndGoverned` used a 1-arg `(achieve "goal")`.
It now uses the 2-arg form.
- Go/JS builds of the value-position sugar listed above are rejected. Before,
they printed "Built" and wrote output that did not compile.

## Not done / caveats
- Optional fetch body: not done. Making a bytecode fetch body fail closed
breaks the locked `TestProdFlipCriteriaLock/fetch_body` and
`nested_multi_effect_*` ABI conformance cases. Those cases pin the
documented "body operand is skipped" behavior. Changing them is a decision
about the flip criteria, so it stays out of this slice.
- Arity is checked on the head symbol only. Sub-form shapes (switch clauses,
route tuples) are still validated by each backend.
- The Go/JS value-position guard is conservative. A value `if` is rejected
even in a spot where a backend might have special-cased it. No existing
test or benchmark relied on that.
- No production -compile-bc/HFIR flip, #90 untouched, no tip-lock, no live
LLM, no DOM. No new language features and no token claims.
95 changes: 95 additions & 0 deletions howlframe.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ func main() {
ast.ApplyPatches(root)
root = ast.ApplyWithContext(root, nil)
root = ast.ApplyWithContext(root, nil)
construct.EnforceArity(root)
ast.Desugar(root)
analysis := checker.Check(root)
hfirModule := filepath.Base(inputFile)
Expand Down Expand Up @@ -694,6 +695,7 @@ func checkSource() {
ast.ApplyPatches(root)
root = ast.ApplyWithContext(root, nil)
root = ast.ApplyWithContext(root, nil)
construct.EnforceArity(root)
ast.Desugar(root)
if diags := hfir.VerifyProfile(root, profile, inputFile, ""); len(diags) > 0 {
reportHFIRDiagnostics(diags)
Expand Down Expand Up @@ -789,11 +791,13 @@ func buildSource() {
ast.ApplyPatches(root)
root = ast.ApplyWithContext(root, nil)
root = ast.ApplyWithContext(root, nil)
construct.EnforceArity(root)
ast.Desugar(root)
if diags := hfir.VerifyProfile(root, profile, inputFile, ""); len(diags) > 0 {
reportHFIRDiagnostics(diags)
}
rejectUnsupportedTargetSugar(root, strings.ToLower(strings.TrimSpace(*target)))
rejectUnsupportedTargetValueForms(root, strings.ToLower(strings.TrimSpace(*target)))

analysis := checker.Check(root)
hfirModule := filepath.Base(inputFile)
Expand Down Expand Up @@ -971,6 +975,7 @@ func runArtifact() {
ast.ApplyPatches(root)
root = ast.ApplyWithContext(root, nil)
root = ast.ApplyWithContext(root, nil)
construct.EnforceArity(root)
ast.Desugar(root)
_ = checker.Check(root)
hfirModule := filepath.Base(inputFile)
Expand Down Expand Up @@ -999,6 +1004,7 @@ func runArtifact() {
ast.ApplyPatches(root)
root = ast.ApplyWithContext(root, nil)
root = ast.ApplyWithContext(root, nil)
construct.EnforceArity(root)
ast.Desugar(root)
_ = checker.Check(root)
hfirModule := filepath.Base(inputFile)
Expand Down Expand Up @@ -1147,3 +1153,92 @@ func rejectUnsupportedTargetSugar(root *ast.Node, target string) {
}
walk(root)
}

// targetStatementForms are heads whose sub-forms the Go/JS backends emit as
// statements. Their bodies are statement positions; see targetValueArgs for
// which operands are values.
var targetStatementForms = map[string]bool{
"cli_app": true, "web_app": true, "wasm_app": true, "do": true, "let": true, "if": true,
"for": true, "while": true, "defun": true, "lambda": true, "switch": true,
"cond": true, "try": true, "try_let": true, "catch": true, "route": true,
"http_server": true, "test": true, "spawn": true, "task": true,
}

// rejectUnsupportedTargetValueForms fails closed when a let, do, if, for or
// while is used as a value (an operand, a let binding or a call argument) on
// --target=go|js. Those backends emit these forms as statements, so the
// output would not compile; desugared sugar (all/any, ver_gt/ver_ge,
// starts_with, list_contains, map_has, not, list_get/parse_json operand
// binding, ...) reaches the backend in exactly this shape (#141).
func rejectUnsupportedTargetValueForms(root *ast.Node, target string) {
if target != "go" && target != "js" && target != "javascript" {
return
}
isForm := func(n *ast.Node) string {
if n == nil || n.Type != "List" || len(n.Children) == 0 || n.Children[0].Type != "SYMBOL" {
return ""
}
switch h := n.Children[0].Value; h {
case "let", "do", "if", "for", "while":
return h
}
return ""
}
var walk func(n *ast.Node, value bool)
walk = func(n *ast.Node, value bool) {
if n == nil {
return
}
if value {
if h := isForm(n); h != "" {
ast.ReportError(fmt.Sprintf("UNSUPPORTED_ON_TARGET: %s used as a value is not supported on --target=%s (this backend emits it as a statement, so the output would not compile; sugar such as all/any, ver_gt/ver_ge, not, starts_with/ends_with, list_contains, map_has, nz, the core-library built-ins and list_get/parse_json on a computed operand lowers to this shape); build for the bytecode target instead", h, target), n.Line, n.Column)
return
}
}
if n.Type != "List" || len(n.Children) == 0 {
for _, c := range n.Children {
walk(c, false)
}
return
}
head := ""
if n.Children[0].Type == "SYMBOL" {
head = n.Children[0].Value
}
args := n.Children[1:]
switch {
case head == "let" && len(args) >= 1:
b := args[0]
for k := 1; k < len(b.Children); k += 2 {
walk(b.Children[k], true)
}
for _, c := range args[1:] {
walk(c, false)
}
case head == "if" && len(args) >= 1:
walk(args[0], true)
for _, c := range args[1:] {
walk(c, value)
}
case (head == "for") && len(args) >= 2:
walk(args[1], true)
for _, c := range args[2:] {
walk(c, false)
}
case head == "while" && len(args) >= 1:
walk(args[0], true)
for _, c := range args[1:] {
walk(c, false)
}
case targetStatementForms[head] || head == "":
for _, c := range n.Children {
walk(c, false)
}
default:
for _, c := range args {
walk(c, true)
}
}
}
walk(root, false)
}
24 changes: 15 additions & 9 deletions internal/backend/gogen/gogen.go
Original file line number Diff line number Diff line change
Expand Up @@ -818,8 +818,8 @@ func GenerateCode(node *ast.Node) (string, string) {
if isCliApp {
startIndex = 1
} else {
if len(node.Children) < 3 {
// ast.ReportError("http_server expects at least a port and 1 route", head.Line, head.Column)
if len(node.Children) < 2 {
ast.ReportError(fmt.Sprintf("ARITY_ERROR: %s expects at least a port", head.Value), head.Line, head.Column)
}
portNode = node.Children[1]
if portNode.Type != "INT" {
Expand Down Expand Up @@ -857,7 +857,7 @@ func GenerateCode(node *ast.Node) (string, string) {
for i := 0; i < len(handlers); i++ {
handlerNode := handlers[i]
if handlerNode.Type != "List" || len(handlerNode.Children) == 0 {
// ast.ReportError("Expected route, defun, struct, import, test, or middleware definition", handlerNode.Line, handlerNode.Column)
ast.ReportError("Expected route, defun, struct, import, test, or middleware definition", handlerNode.Line, handlerNode.Column)
}

head := handlerNode.Children[0].Value
Expand Down Expand Up @@ -1398,6 +1398,12 @@ func generateStatement(node *ast.Node, reqVar string, depth int) string {
}

func generateExpression(node *ast.Node, reqVar string, depth int) string {
if node != nil && node.Type == "List" && len(node.Children) == 1 &&
node.Children[0].Type == "SYMBOL" && node.Children[0].Value == "time_now" {
// Unix seconds, matching the bytecode VM and the JS backend. Before
// #141 time_now emitted an empty expression on --target=go.
return "int(time.Now().Unix())"
}
return generateStatementRaw(node, reqVar, depth)
}

Expand Down Expand Up @@ -1900,7 +1906,7 @@ func generateStatementRaw(node *ast.Node, reqVar string, depth int) string {
return generateExpression(node.Children[1], reqVar, depth+1)
} else if head == "trace" {
if len(node.Children) != 2 {
// ast.ReportError("trace expects (trace var)", node.Line, node.Column)
ast.ReportError("trace expects (trace var)", node.Line, node.Column)
}
varStr := generateStatement(node.Children[1], reqVar, depth+1)
fileLine := fmt.Sprintf("[%s:%d]", node.Filename, node.Line)
Expand Down Expand Up @@ -2178,7 +2184,7 @@ func generateStatementRaw(node *ast.Node, reqVar string, depth int) string {
}()`, instructionStr, promptVar)
} else if head == "fuzzy_cast" {
if len(node.Children) < 3 {
// ast.ReportError("fuzzy_cast expects (fuzzy_cast Type var [model])", node.Line, node.Column)
ast.ReportError("fuzzy_cast expects (fuzzy_cast Type var [model])", node.Line, node.Column)
}
typeStr := node.Children[1].Value
varStr := generateStatement(node.Children[2], reqVar, depth+1)
Expand Down Expand Up @@ -2207,16 +2213,16 @@ func generateStatementRaw(node *ast.Node, reqVar string, depth int) string {
}()`, typeStr, typeStr, modelStr, varStr)
} else if head == "semantic_match" {
if len(node.Children) < 3 {
// ast.ReportError("semantic_match expects (semantic_match input (intent consequence)...)", node.Line, node.Column)
ast.ReportError("semantic_match expects (semantic_match input (intent consequence)...)", node.Line, node.Column)
}
varStr := generateStatement(node.Children[1], reqVar, depth+1)
var casesStr string
var intentExprs []string

for i := 2; i < len(node.Children); i++ {
caseNode := node.Children[i]
if caseNode.Type != "LIST" || len(caseNode.Children) < 2 {
// ast.ReportError("semantic_match case expects (intent consequence...)", caseNode.Line, caseNode.Column)
if caseNode.Type != "List" || len(caseNode.Children) < 2 {
ast.ReportError("semantic_match case expects (intent consequence...)", caseNode.Line, caseNode.Column)
}
label := caseNode.Children[0]
isDefault := label.Value == "default" || label.Value == "_"
Expand Down Expand Up @@ -2265,7 +2271,7 @@ func generateStatementRaw(node *ast.Node, reqVar string, depth int) string {
}()`, intentsArrayStr, varStr, casesStr)
} else if head == "assert_semantic" {
if len(node.Children) != 3 {
// ast.ReportError("assert_semantic expects (assert_semantic var \"condition\")", node.Line, node.Column)
ast.ReportError("assert_semantic expects (assert_semantic var \"condition\")", node.Line, node.Column)
}
varStr := generateStatement(node.Children[1], reqVar, depth+1)
condStr := generateStatement(node.Children[2], reqVar, depth+1)
Expand Down
28 changes: 21 additions & 7 deletions internal/bytecode/bytecode.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"crypto/sha256"
"encoding/json"
"fmt"
"github.com/howlcipher/howlframe/internal/construct"

"github.com/howlcipher/howlframe/internal/ast"
"github.com/howlcipher/howlframe/internal/httpreq"
Expand Down Expand Up @@ -360,6 +361,18 @@ func (c *BCCompiler) compileNode(node *ast.Node) []BCInstruction {

if node.Type == "List" && len(node.Children) > 0 {
head := node.Children[0].Value
// Table-driven arity backstop (#141). The CLI enforces the same
// construct.arities table before desugaring; this keeps a direct
// CompileToBytecode caller from indexing past a short child list.
if node.Children[0].Type == "SYMBOL" {
if want, ok := construct.LookupArity(head); ok {
if got := len(node.Children) - 1; got < want.Min || (want.Max >= 0 && got > want.Max) {
v := construct.ArityViolation{Name: head, Got: got, Want: want}
ast.ReportError(v.Message(), node.Line, node.Column)
return insts
}
}
}
switch head {
case "cli_app", "wasm_app", "web_app":
for _, child := range node.Children[1:] {
Expand Down Expand Up @@ -483,13 +496,8 @@ func (c *BCCompiler) compileNode(node *ast.Node) []BCInstruction {
}
insts = append(insts, BCInstruction{OpString: "STORE_KEYS", Op: OpStoreKeys, StringOperand: handleNode.Value})
case "fetch":
// (fetch url method [body]). Arity is checked before any child is
// indexed: a one-argument (fetch url) used to index past the end
// and panic the compiler (#139). Fail closed with a diagnostic.
if len(node.Children) != 3 && len(node.Children) != 4 {
ast.ReportError("fetch expects (fetch url method [body])", node.Line, node.Column)
return insts
}
// (fetch url method [body]). Arity (#139) is enforced by the
// construct.arities table above and before desugaring (#141).
insts = append(insts, c.compileNode(node.Children[1])...)
insts = append(insts, c.compileNode(node.Children[2])...)
insts = append(insts, BCInstruction{OpString: "FETCH", Op: OpFetch})
Expand Down Expand Up @@ -727,6 +735,12 @@ func (c *BCCompiler) compileNode(node *ast.Node) []BCInstruction {
}
insts = append(insts, BCInstruction{OpString: "CALL", Op: OpCall, StringOperand: funcName, IntOperand: int64(float64(len(node.Children) - 2))})
case "and", "or":
if len(node.Children) != 3 {
// N-ary and/or folds to binary in desugar; one that reaches
// here unfolded would silently drop operands (#141).
ast.ReportError(fmt.Sprintf("ARITY_ERROR: %s with %d operands was not folded to binary form in this position", head, len(node.Children)-1), node.Line, node.Column)
return insts
}
insts = append(insts, ShortCircuit(head, c.compileNode(node.Children[1]), c.compileNode(node.Children[2]))...)
case "+", "-", "*", "/", "<", ">", "<=", ">=", "==", "!=", "=":
insts = append(insts, c.compileNode(node.Children[1])...)
Expand Down
Loading
Loading