diff --git a/docs/journals/2026-10-12_issue141_arity_target_sweep.md b/docs/journals/2026-10-12_issue141_arity_target_sweep.md new file mode 100644 index 00000000..0ce00491 --- /dev/null +++ b/docs/journals/2026-10-12_issue141_arity_target_sweep.md @@ -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:
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. diff --git a/howlframe.go b/howlframe.go index edb14b64..79a3558e 100644 --- a/howlframe.go +++ b/howlframe.go @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) +} diff --git a/internal/backend/gogen/gogen.go b/internal/backend/gogen/gogen.go index b5111bb9..f764e85d 100644 --- a/internal/backend/gogen/gogen.go +++ b/internal/backend/gogen/gogen.go @@ -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" { @@ -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 @@ -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) } @@ -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) @@ -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) @@ -2207,7 +2213,7 @@ 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 @@ -2215,8 +2221,8 @@ func generateStatementRaw(node *ast.Node, reqVar string, depth int) 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 == "_" @@ -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) diff --git a/internal/bytecode/bytecode.go b/internal/bytecode/bytecode.go index 79a3f9c3..0a073387 100644 --- a/internal/bytecode/bytecode.go +++ b/internal/bytecode/bytecode.go @@ -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" @@ -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:] { @@ -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}) @@ -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])...) diff --git a/internal/construct/arity.go b/internal/construct/arity.go new file mode 100644 index 00000000..26c8349e --- /dev/null +++ b/internal/construct/arity.go @@ -0,0 +1,249 @@ +package construct + +import ( + "fmt" + + "github.com/howlcipher/howlframe/internal/ast" +) + +// Arity is the argument count a construct accepts: Min..Max arguments after +// the head symbol. Max < 0 means variadic. +type Arity struct { + Min, Max int +} + +// Variadic marks an open upper bound. +const Variadic = -1 + +// arities declares the argument count of every registry construct that is +// checked (#141). One table drives every backend: the CLI enforces it before +// desugaring and before any bytecode, HFIR, interpreter, Go or JS codegen, so a +// wrong arity is a coded ARITY_ERROR instead of an index-out-of-range panic. +// arity_test.go fails if a registry entry has neither an arity here +// nor an exemption in arityExempt. +var arities = map[string]Arity{ + // export and module survive ResolveModules when malformed, so they need + // an arity (an empty one used to panic in the checker). + "export": {1, Variadic}, "module": {1, Variadic}, + "!=": {2, 2}, "*": {2, 2}, "+": {2, 2}, "-": {2, 2}, "/": {2, 2}, + "<": {2, 2}, "<=": {2, 2}, "=": {2, 2}, "==": {2, 2}, ">": {2, 2}, ">=": {2, 2}, + "and": {2, Variadic}, "or": {2, Variadic}, // n-ary folds in desugar + "achieve": {2, 2}, "all": {3, 3}, "any": {3, 3}, "append": {2, 2}, "assert": {1, 1}, + "attr_escape": {1, 1}, "blank": {1, 1}, "bytes_to_string": {1, 1}, "call": {1, Variadic}, + "cli_app": {0, Variadic}, "cli_args": {0, 1}, "cond": {1, Variadic}, "confidence": {1, 1}, + "count_by": {3, 3}, "db_connect": {3, 3}, "dec": {1, 1}, "defun": {3, Variadic}, + "dict": {0, Variadic}, "do": {0, Variadic}, "encode_json": {1, 1}, "ends_with": {2, 2}, + "env": {1, 1}, "ephemeral_circuit": {2, Variadic}, "exec": {1, Variadic}, "exit": {1, 1}, + "fetch": {2, 3}, "filter": {3, 3}, "for": {3, 3}, "html_escape": {1, 1}, + "http_body": {1, 1}, "http_request": {2, 3}, "http_res": {1, 1}, "http_server": {1, Variadic}, + "http_status": {1, 1}, "if": {2, 3}, "inc": {1, 1}, "index_of": {2, 2}, "is_nil": {1, 1}, + "lazy_synthesize": {3, 3}, "let": {2, 2}, "lines": {1, 1}, "list": {0, Variadic}, + "list_contains": {2, 2}, "list_get": {2, 2}, "list_len": {1, 1}, "list_slice": {2, 3}, + "llm_generate": {1, 2}, "map_delete": {2, 2}, "map_get": {2, 2}, "map_has": {2, 2}, + "map_keys": {1, 1}, "map_list": {3, 3}, "map_set": {3, 3}, "max": {1, 1}, "min": {1, 1}, + "mkdir": {1, 1}, "neural_circuit": {2, Variadic}, "not": {1, 1}, "nz": {2, 2}, + "optimize_block": {3, Variadic}, "optimize_signature": {1, Variadic}, "parse_json": {2, 2}, + "print": {0, Variadic}, "read_file": {1, 1}, "read_line": {0, 0}, "read_text": {1, 1}, + "reduce": {5, 5}, "regex_match": {2, 2}, "req_header": {2, 2}, "req_method": {1, 1}, + "req_path": {2, 2}, "req_query": {2, 2}, "res": {3, 3}, "res_header": {2, 2}, + "res_json": {2, 2}, "return": {1, 1}, "reverse": {1, 1}, "schema_bridge": {2, 2}, + "set": {2, 2}, "sget": {2, 2}, "sinc": {2, 2}, "sleep": {1, 1}, "sort": {1, 1}, + "spawn": {1, 1}, "spawn_agent": {2, 2}, "sput": {3, 3}, "sql_query": {2, Variadic}, + "starts_with": {2, 2}, "stderr": {1, 1}, "store_delete": {2, 2}, "store_get": {2, 2}, + "store_keys": {1, 1}, "store_open": {2, 2}, "store_put": {3, 3}, "str": {1, Variadic}, + "str_contains": {2, 2}, "str_join": {2, 2}, "str_len": {1, 1}, "str_replace": {3, 3}, + "str_slice": {2, 3}, "str_split": {2, 2}, "str_trim": {1, 1}, "sum": {1, 1}, + "switch": {1, Variadic}, "task": {1, Variadic}, "time_now": {0, 0}, "to_float": {1, 1}, + "to_int": {1, 1}, "to_lower": {1, 1}, "to_string": {1, 1}, "to_upper": {1, 1}, + "try_let": {2, Variadic}, "unix_now": {0, 0}, "unless": {1, Variadic}, "ver_ge": {2, 2}, + "ver_gt": {2, 2}, "wasm_app": {0, Variadic}, "web_app": {0, Variadic}, "when": {1, Variadic}, + "while": {2, 2}, "write_file": {2, 2}, +} + +// arityExempt lists registry constructs with no arity entry, each with the +// reason the check does not apply. +var arityExempt = map[string]string{ + "assert_semantic": "Unsupported in the construct gate; the Go backend checks its shape (fail closed since #141)", + "fuzzy_cast": "Unsupported in the construct gate; the Go backend checks its shape (fail closed since #141)", + "go_import": "Unsupported directive; rejected by the construct gate", + "import": "Unsupported directive; rejected by the construct gate", + "match": "Unsupported: rejected by the construct gate and the interpreter", + "middleware": "Unsupported: rejected by the construct gate; Go backend validates its own shape", + "schema": "Unsupported: declarative, validated by the Go backend", + "semantic_match": "Unsupported in the construct gate; the Go backend checks its shape (fail closed since #141)", + "struct": "Unsupported: declaration shape validated by the checker", + "test": "Unsupported: extracted into Go _test.go output", + "trace": "Unsupported in the construct gate; the Go backend checks its shape (fail closed since #141)", + "include": "consumed by parser.ExpandIncludes before the arity check runs", + "use": "consumed by ast.ResolveModules before the arity check runs", + "patch": "consumed by ast.ApplyPatches before the arity check runs", + "with_context": "consumed by ast.ApplyWithContext before the arity check runs", + "type_hint": "pure annotation; children are type names", + "type_hints": "pure annotation; children are type names", + "type_param": "pure annotation; children are type names", +} + +// LookupArity returns the declared arity of name. +func LookupArity(name string) (Arity, bool) { + a, ok := arities[name] + return a, ok +} + +// ArityExemptions returns the exempt constructs and their reasons. +func ArityExemptions() map[string]string { + out := make(map[string]string, len(arityExempt)) + for k, v := range arityExempt { + out[k] = v + } + return out +} + +// ArityViolation is one construct called with the wrong number of arguments. +type ArityViolation struct { + Name string + Got int + Want Arity + Line, Column int +} + +func plural(n int) string { + if n == 1 { + return "arg" + } + return "args" +} + +// Message renders the coded diagnostic, e.g. +// "ARITY_ERROR: read_file expects 1 arg, got 0". +func (v ArityViolation) Message() string { + var want string + switch { + case v.Want.Max == v.Want.Min: + want = fmt.Sprintf("%d %s", v.Want.Min, plural(v.Want.Min)) + case v.Want.Max < 0: + want = fmt.Sprintf("at least %d %s", v.Want.Min, plural(v.Want.Min)) + default: + want = fmt.Sprintf("%d to %d args", v.Want.Min, v.Want.Max) + } + return fmt.Sprintf("ARITY_ERROR: %s expects %s, got %d", v.Name, want, v.Got) +} + +// CheckArity walks the AST in construct position (binding lists, parameter +// lists, dict pairs, sub-forms and opaque operands are structural and are not +// checked as calls) and returns every arity violation in source order. +func CheckArity(root *ast.Node) []ArityViolation { + var out []ArityViolation + arityNode(root, "", &out) + return out +} + +// EnforceArity reports the first arity violation as a fail-closed diagnostic +// (JSON reason, exit 1) before anything is compiled, written or run. +func EnforceArity(root *ast.Node) { + if vs := CheckArity(root); len(vs) > 0 { + ast.ReportError(vs[0].Message(), vs[0].Line, vs[0].Column) + } +} + +func arityNode(node *ast.Node, parent string, out *[]ArityViolation) { + if node == nil || node.Type != "List" || len(node.Children) == 0 { + return + } + head := node.Children[0] + if head.Type != "SYMBOL" { + arityChildren(node, 0, out) + return + } + name := head.Value + if isSubForm(name, parent) { + switch name { + case "metric", "candidate", "test", "column": + return + case "catch", "lambda", "route": + arityChildren(node, 2, out) + default: + arityChildren(node, 1, out) + } + return + } + if _, exempt := arityExempt[name]; exempt { + return + } + want, known := arities[name] + if !known { + // A user function call or an unknown head: not a registry + // construct, but its arguments still are code. + arityChildren(node, 1, out) + return + } + got := len(node.Children) - 1 + if got < want.Min || (want.Max >= 0 && got > want.Max) { + *out = append(*out, ArityViolation{Name: name, Got: got, Want: want, Line: head.Line, Column: head.Column}) + return + } + if entry, ok := Lookup(name); ok && entry.Opaque { + return + } + switch name { + case "let", "try_let": + if b := node.Children[1]; b != nil && b.Type == "List" { + for i := 1; i < len(b.Children); i += 2 { + arityNode(b.Children[i], "", out) + } + } + arityChildren(node, 2, out) + if name == "try_let" { + return + } + case "defun": + arityChildren(node, 3, out) + case "lambda": + arityChildren(node, 2, out) + case "dict": + for _, pair := range node.Children[1:] { + if pair != nil && pair.Type == "List" { + arityChildren(pair, 0, out) + } + } + case "neural_circuit", "ephemeral_circuit": + if a := node.Children[1]; a != nil && a.Type == "List" { + arityChildren(a, 0, out) + } + arityChildren(node, 2, out) + case "switch": + arityNode(node.Children[1], name, out) + for _, clause := range node.Children[2:] { + if clause != nil && clause.Type == "List" { + arityChildren(clause, 1, out) + } + } + case "cond": + for _, clause := range node.Children[1:] { + if clause != nil && clause.Type == "List" { + arityChildren(clause, 0, out) + } + } + default: + arityChildren(node, 1, out) + } +} + +func arityChildren(node *ast.Node, from int, out *[]ArityViolation) { + head := "" + if len(node.Children) > 0 && node.Children[0].Type == "SYMBOL" { + head = node.Children[0].Value + } + for i := from; i < len(node.Children); i++ { + arityNode(node.Children[i], head, out) + } +} + +// ArityTable returns a copy of the arity table, for the CLI tests that drive +// every entry through every backend. +func ArityTable() map[string]Arity { + out := make(map[string]Arity, len(arities)) + for k, v := range arities { + out[k] = v + } + return out +} diff --git a/internal/construct/arity_test.go b/internal/construct/arity_test.go new file mode 100644 index 00000000..845a1332 --- /dev/null +++ b/internal/construct/arity_test.go @@ -0,0 +1,83 @@ +package construct + +import ( + "strings" + "testing" +) + +// TestArityTableCoversRegistry is the drift guard for #141: every registry +// entry must declare an arity or carry an exemption reason, and neither table +// may name a construct the registry no longer has. +func TestArityTableCoversRegistry(t *testing.T) { + names := map[string]bool{} + for _, e := range Table() { + names[e.Name] = true + a, hasArity := arities[e.Name] + reason, exempt := arityExempt[e.Name] + switch { + case hasArity && exempt: + t.Errorf("%s has both an arity and an exemption", e.Name) + case !hasArity && !exempt: + t.Errorf("%s has no arity; add it to arities in arity.go (or arityExempt with a reason)", e.Name) + case exempt && strings.TrimSpace(reason) == "": + t.Errorf("%s exemption has no reason", e.Name) + case hasArity && (a.Min < 0 || (a.Max != Variadic && a.Max < a.Min)): + t.Errorf("%s arity %+v is malformed", e.Name, a) + } + if e.Support == Unsupported && !exempt { + t.Errorf("%s is Unsupported and should be exempt (the profile scan reports it)", e.Name) + } + } + for name := range arities { + if !names[name] { + t.Errorf("arities has stale entry %s", name) + } + } + for name := range arityExempt { + if !names[name] { + t.Errorf("arityExempt has stale entry %s", name) + } + } + t.Logf("registry=%d arities=%d exempt=%d", len(names), len(arities), len(arityExempt)) +} + +func arityMessages(t *testing.T, src string) []string { + t.Helper() + var out []string + for _, v := range CheckArity(parse(t, src).ParseExpression()) { + out = append(out, v.Message()) + } + return out +} + +func TestArityMessages(t *testing.T) { + cases := map[string]string{ + `(cli_app (read_file))`: "ARITY_ERROR: read_file expects 1 arg, got 0", + `(cli_app (print (str_trim)))`: "ARITY_ERROR: str_trim expects 1 arg, got 0", + `(cli_app (fetch "u"))`: "ARITY_ERROR: fetch expects 2 to 3 args, got 1", + `(cli_app (defun f (x)))`: "ARITY_ERROR: defun expects at least 3 args, got 2", + `(cli_app (write_file "a" "b" "c"))`: "ARITY_ERROR: write_file expects 2 args, got 3", + `(cli_app (let (x (read_file)) (print x)))`: "ARITY_ERROR: read_file expects 1 arg, got 0", + } + for src, want := range cases { + got := arityMessages(t, src) + if len(got) == 0 || got[0] != want { + t.Errorf("%s: got %v, want %q", src, got, want) + } + } +} + +func TestArityAcceptsWellFormed(t *testing.T) { + for _, src := range []string{ + `(cli_app (let (min 0 max 3) (print (+ min max))))`, + `(cli_app (defun f (a b c d) (print a)) (f 1 2 3 4))`, + `(cli_app (print (and true true false)))`, + `(cli_app (print (fetch "u" "GET")))`, + `(cli_app (let (d (dict ("a" 1) ("b" 2))) (print d)))`, + `(cli_app (print))`, + } { + if got := arityMessages(t, src); len(got) != 0 { + t.Errorf("%s: unexpected %v", src, got) + } + } +} diff --git a/issue139_corelib_hardening_cli_test.go b/issue139_corelib_hardening_cli_test.go index 5654685b..9512a06c 100644 --- a/issue139_corelib_hardening_cli_test.go +++ b/issue139_corelib_hardening_cli_test.go @@ -27,7 +27,7 @@ func TestIssue139FetchArityFailsClosed(t *testing.T) { if strings.Contains(string(out), "panic") || strings.Contains(string(out), "goroutine") { t.Fatalf("%s: compiler panicked: %s", profile, out) } - if !strings.Contains(string(out), "fetch expects (fetch url method [body])") { + if !strings.Contains(string(out), "ARITY_ERROR: fetch expects 2 to 3 args, got 1") { t.Fatalf("%s: missing diagnostic: %s", profile, out) } if _, err := os.Stat(art); err == nil { diff --git a/issue141_arity_cli_test.go b/issue141_arity_cli_test.go new file mode 100644 index 00000000..76f28368 --- /dev/null +++ b/issue141_arity_cli_test.go @@ -0,0 +1,255 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "strings" + "sync" + "testing" + "time" + + "github.com/howlcipher/howlframe/internal/construct" +) + +// issue141Run runs the CLI with a timeout and empty stdin. It never executes +// a program that passes the arity check except through build-only commands. +func issue141Run(bin string, args ...string) (string, int) { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, bin, args...) + cmd.Stdin = strings.NewReader("") + raw, err := cmd.CombinedOutput() + // ReportError emits JSON, which escapes <, > and &. + out := strings.NewReplacer(`\u003c`, "<", `\u003e`, ">", `\u0026`, "&").Replace(string(raw)) + code := 0 + if err != nil { + code = -1 + if ee, ok := err.(*exec.ExitError); ok { + code = ee.ExitCode() + } + } + return out, code +} + +func issue141Panicked(out string) bool { + return strings.Contains(out, "panic:") || strings.Contains(out, "goroutine ") || strings.Contains(out, "runtime error") +} + +// issue141Form builds (head 0 0 ...) with n placeholder operands. +func issue141Form(head string, n int) string { + args := strings.Repeat(" 0", n) + return "(" + head + args + ")" +} + +func issue141Source(form string) string { + return "(cli_app\n " + form + ")\n" +} + +// assertArityFailClosed builds src under both profiles and requires exit 1, +// no panic, no artifact, and a coded ARITY_ERROR (governed may reject a +// forbidden construct first with PROFILE_FORBIDDEN_CONSTRUCT). +func assertArityFailClosed(t *testing.T, bin, dir, name, src, want string) { + t.Helper() + source := filepath.Join(dir, name+".howl") + if err := os.WriteFile(source, []byte(src), 0o600); err != nil { + t.Fatal(err) + } + for _, profile := range []string{"default", "governed"} { + art := filepath.Join(dir, name+"."+profile+".hfbc") + out, code := issue141Run(bin, "build", "--profile", profile, source, "-o", art) + if code != 1 || issue141Panicked(out) { + t.Errorf("%s [%s]: exit %d, want 1 without panic: %s", name, profile, code, out) + } + ok := strings.Contains(out, "ARITY_ERROR") + if want != "" && profile == "default" { + ok = strings.Contains(out, want) + } + if profile == "governed" && strings.Contains(out, "PROFILE_FORBIDDEN_CONSTRUCT") { + ok = true + } + if !ok { + t.Errorf("%s [%s]: want %q, got %s", name, profile, want, out) + } + if _, err := os.Stat(art); !os.IsNotExist(err) { + t.Errorf("%s [%s]: artifact written", name, profile) + } + } +} + +func TestIssue141MotokoArityRepros(t *testing.T) { + bin := c1c2Binary(t) + dir := t.TempDir() + cases := []struct{ name, form, want string }{ + {"read_file0", `(read_file)`, "ARITY_ERROR: read_file expects 1 arg, got 0"}, + {"write_file0", `(write_file)`, "ARITY_ERROR: write_file expects 2 args, got 0"}, + {"write_file1", `(write_file "a")`, "ARITY_ERROR: write_file expects 2 args, got 1"}, + {"mkdir0", `(mkdir)`, "ARITY_ERROR: mkdir expects"}, + {"env0", `(print (env))`, "ARITY_ERROR: env expects"}, + {"task0", `(task)`, "ARITY_ERROR: task expects"}, + {"res0", `(res)`, "ARITY_ERROR: res expects 3 args, got 0"}, + {"res1", `(res 200)`, "ARITY_ERROR: res expects 3 args, got 1"}, + {"res2", `(res 200 "text/plain")`, "ARITY_ERROR: res expects 3 args, got 2"}, + {"res_json0", `(res_json)`, "ARITY_ERROR: res_json expects 2 args, got 0"}, + {"res_json1", `(res_json 200)`, "ARITY_ERROR: res_json expects 2 args, got 1"}, + {"store_open0", `(store_open)`, "ARITY_ERROR: store_open expects"}, + {"defun0", `(defun)`, "ARITY_ERROR: defun expects at least 3 args, got 0"}, + {"defun1", `(defun f)`, "ARITY_ERROR: defun expects at least 3 args, got 1"}, + {"http_server0", `(http_server)`, "ARITY_ERROR: http_server expects at least 1 arg, got 0"}, + {"str_trim_in_print", `(print (str_trim))`, "ARITY_ERROR: str_trim expects 1 arg, got 0"}, + {"exec0", `(print (exec))`, "ARITY_ERROR: exec expects at least 1 arg, got 0"}, + {"spawn_agent0", `(spawn_agent)`, "ARITY_ERROR: spawn_agent expects 2 args, got 0"}, + {"spawn_agent1", `(spawn_agent "x")`, "ARITY_ERROR: spawn_agent expects 2 args, got 1"}, + {"llm_generate0", `(print (llm_generate))`, "ARITY_ERROR: llm_generate expects"}, + {"sql_query0", `(print (sql_query))`, "ARITY_ERROR: sql_query expects"}, + {"sql_query1", `(print (sql_query db))`, "ARITY_ERROR: sql_query expects"}, + {"db_connect0", `(db_connect)`, "ARITY_ERROR: db_connect expects 3 args, got 0"}, + {"db_connect1", `(db_connect db)`, "ARITY_ERROR: db_connect expects 3 args, got 1"}, + {"db_connect2", `(db_connect db "sqlite3")`, "ARITY_ERROR: db_connect expects 3 args, got 2"}, + {"lazy_synthesize0", `(lazy_synthesize)`, "ARITY_ERROR: lazy_synthesize expects 3 args, got 0"}, + {"lazy_synthesize1", `(lazy_synthesize f)`, "ARITY_ERROR: lazy_synthesize expects 3 args, got 1"}, + {"lazy_synthesize2", `(lazy_synthesize f ())`, "ARITY_ERROR: lazy_synthesize expects 3 args, got 2"}, + {"confidence0", `(print (confidence))`, "ARITY_ERROR: confidence expects"}, + {"fetch1", `(print (fetch "http://127.0.0.1:1/"))`, "ARITY_ERROR: fetch expects 2 to 3 args, got 1"}, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assertArityFailClosed(t, bin, dir, tc.name, issue141Source(tc.form), tc.want) + }) + } +} + +// issue141TopLevel heads are program roots, not statements inside cli_app. +var issue141TopLevel = map[string]bool{"cli_app": true, "web_app": true, "wasm_app": true} + +func issue141ProgramFor(head string, n int) string { + if issue141TopLevel[head] { + return issue141Form(head, n) + "\n" + } + return issue141Source(issue141Form(head, n)) +} + +func issue141SortedTable() ([]string, map[string]construct.Arity) { + table := construct.ArityTable() + names := make([]string, 0, len(table)) + for n := range table { + names = append(names, n) + } + sort.Strings(names) + return names, table +} + +// TestIssue141EveryArityEntry drives one too-few and one too-many call for +// every table entry through both build profiles. +func TestIssue141EveryArityEntry(t *testing.T) { + bin := c1c2Binary(t) + dir := t.TempDir() + names, table := issue141SortedTable() + checked := 0 + for _, name := range names { + a := table[name] + var bad []int + if a.Min > 0 { + bad = append(bad, a.Min-1) + } + if a.Max != construct.Variadic { + bad = append(bad, a.Max+1) + } + for _, n := range bad { + name, n := name, n + checked++ + want := fmt.Sprintf("ARITY_ERROR: %s expects", name) + t.Run(fmt.Sprintf("%s/%d", name, n), func(t *testing.T) { + t.Parallel() + id := fmt.Sprintf("e%x_%d", []byte(name), n) + assertArityFailClosed(t, bin, dir, id, issue141ProgramFor(name, n), want) + }) + } + } + if checked < len(table) { + t.Fatalf("only %d wrong-arity cases for %d entries", checked, len(table)) + } +} + +// TestIssue141ArityFuzzNoPanic tries arities 0..N for every registry +// built-in on every build path and asserts the CLI never panics. Only build +// commands run, so no program is executed (no live LLM, network or exec); +// -run is exercised only with arities the table rejects. +func TestIssue141ArityFuzzNoPanic(t *testing.T) { + bin := c1c2Binary(t) + dir := t.TempDir() + var names []string + for _, e := range construct.Table() { + names = append(names, e.Name) + } + table := construct.ArityTable() + sem := make(chan struct{}, 8) + var wg sync.WaitGroup + var mu sync.Mutex + var failures []string + runs := 0 + for _, name := range names { + a, known := table[name] + top := 4 + if known && a.Min+1 > top { + top = a.Min + 1 + } + for n := 0; n <= top; n++ { + name, n := name, n + id := fmt.Sprintf("f%x_%d", []byte(name), n) + source := filepath.Join(dir, id+".howl") + if err := os.WriteFile(source, []byte(issue141ProgramFor(name, n)), 0o600); err != nil { + t.Fatal(err) + } + cmds := [][]string{ + {"build", source, "-o", filepath.Join(dir, id+".bc")}, + {"build", "--profile", "governed", source, "-o", filepath.Join(dir, id+".gbc")}, + {"-compile-hfir-bc", source, "-o", filepath.Join(dir, id+".hbc")}, + {"build", "--target=go", source, "-o", filepath.Join(dir, id+"-go")}, + {"build", "--target=js", source, "-o", filepath.Join(dir, id+"-js")}, + } + if known && (n < a.Min || (a.Max != construct.Variadic && n > a.Max)) { + cmds = append(cmds, []string{"-run", source}) + } + for _, args := range cmds { + args := args + runs++ + wg.Add(1) + sem <- struct{}{} + go func() { + defer wg.Done() + defer func() { <-sem }() + out, code := issue141Run(bin, args...) + if issue141Panicked(out) || code == -1 || code == 2 { + mu.Lock() + failures = append(failures, fmt.Sprintf("%s n=%d %v: exit %d %s", name, n, args[:len(args)-1], code, firstLine(out))) + mu.Unlock() + } + }() + } + } + } + wg.Wait() + sort.Strings(failures) + for _, f := range failures { + t.Error(f) + } + t.Logf("%d CLI runs over %d registry built-ins", runs, len(names)) +} + +func firstLine(s string) string { + for _, l := range strings.Split(s, "\n") { + if strings.TrimSpace(l) != "" { + if len(l) > 200 { + return l[:200] + } + return l + } + } + return "" +} diff --git a/issue141_target_sweep_cli_test.go b/issue141_target_sweep_cli_test.go new file mode 100644 index 00000000..37716811 --- /dev/null +++ b/issue141_target_sweep_cli_test.go @@ -0,0 +1,100 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestIssue141TargetNeverFalseBuilt builds one small program per construct +// family for --target=go and --target=js. Each build must either be rejected +// (no "Built") or produce output that compiles: go vet for Go, node --check +// for JS. A "Built" whose output does not compile is a false Built (#141). +func TestIssue141TargetNeverFalseBuilt(t *testing.T) { + bin := c1c2Binary(t) + repo, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + _, nodeErr := exec.LookPath("node") + probes := []struct{ name, body string }{ + {"all", `(let (xs (list 1 2)) (print (all x xs (> x 0))))`}, + {"any", `(let (xs (list 1 2)) (print (any x xs (> x 1))))`}, + {"ver_gt", `(print (ver_gt "1.2.3" "1.2.0"))`}, + {"ver_ge", `(print (ver_ge "1.2.3" "1.2.3"))`}, + {"if_value", `(let (a 1) (print (if (> a 0) "pos" "neg")))`}, + {"if_let_value", `(let (s (if true "y" "n")) (print s))`}, + {"list_get_expr", `(print (list_get (str_split "a,b" ",") 1))`}, + {"parse_json_expr", `(let (d (parse_json Any (str_trim "{}"))) (print d))`}, + {"cond", `(let (a 2) (print (cond ((= a 1) "one") ((= a 2) "two"))))`}, + {"switch", `(let (a 2) (switch a (1 (print "one")) (else (print "other"))))`}, + {"str", `(print (str "a" 1))`}, + {"str_replace", `(print (str_replace "foo" "o" "0"))`}, + {"str_contains", `(print (str_contains "foo" "o"))`}, + {"starts_with", `(print (starts_with "foo" "f"))`}, + {"ends_with", `(print (ends_with "foo" "o"))`}, + {"list_contains", `(let (xs (list "a")) (print (list_contains xs "a")))`}, + {"map_has", `(let (d (dict ("a" "b"))) (print (map_has d "a")))`}, + {"not", `(print (not true))`}, + {"blank", `(print (blank ""))`}, + {"when", `(let (a 1) (when (> a 0) (print "w")))`}, + {"unless", `(let (a 1) (unless (> a 5) (print "u")))`}, + {"inc", `(let (a 1) (do (inc a) (print a)))`}, + {"dec", `(let (a 1) (do (dec a) (print a)))`}, + {"unix_now", `(print (unix_now))`}, + {"lines", `(print (list_len (lines (read_file "x"))))`}, + {"read_text", `(print (read_text "x"))`}, + {"assert", `(assert (= 1 1))`}, + {"nz_stmt", `(let (d (dict ("a" "b"))) (print (nz (map_get d "a") "z")))`}, + {"plain", `(let (xs (list "a" "b")) (print (str_join xs ",")))`}, + {"time_now", `(print (time_now))`}, + {"tostr_time", `(print (to_string (time_now)))`}, + } + for _, target := range []string{"go", "js"} { + for _, p := range probes { + target, p := target, p + t.Run(target+"/"+p.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + src := filepath.Join(dir, "p.howl") + os.WriteFile(src, []byte("(cli_app\n"+p.body+")\n"), 0o600) + outDir := filepath.Join(dir, "out") + out, code := issue141Run(bin, "build", "--target="+target, src, "-o", outDir) + if issue141Panicked(out) { + t.Fatalf("panic: %s", out) + } + if !strings.Contains(out, "Built") { + if code == 0 { + t.Fatalf("no Built but exit 0: %s", out) + } + return // rejected: fail closed + } + if target == "js" { + if nodeErr != nil { + t.Skip("node not installed") + } + files, _ := filepath.Glob(filepath.Join(outDir, "*.js")) + if len(files) == 0 { + t.Fatalf("Built but no .js output") + } + for _, f := range files { + if b, err := exec.Command("node", "--check", f).CombinedOutput(); err != nil { + t.Fatalf("false Built: node --check %s: %s", f, b) + } + } + return + } + mod := "module probe\n\ngo 1.21\n\nrequire github.com/howlcipher/howlframe v0.0.0\n\nreplace github.com/howlcipher/howlframe => " + repo + "\n" + os.WriteFile(filepath.Join(outDir, "go.mod"), []byte(mod), 0o600) + cmd := exec.Command("go", "vet", ".") + cmd.Dir = outDir + cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("false Built: go vet: %s", b) + } + }) + } + } +} diff --git a/issue141_wasm_examples_cli_test.go b/issue141_wasm_examples_cli_test.go new file mode 100644 index 00000000..1317aad1 --- /dev/null +++ b/issue141_wasm_examples_cli_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// TestIssue141WasmExamplesBuildOnGoJS is the regression for the #142 review: +// wasm_app bodies are statement positions, so the wasm examples must still +// build on --target=go|js and the output must compile. +func TestIssue141WasmExamplesBuildOnGoJS(t *testing.T) { + bin := c1c2Binary(t) + repo, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + for _, ex := range []string{"wasm_app_hello", "wasm_app_print", "wasm_math"} { + for _, target := range []string{"go", "js"} { + ex, target := ex, target + t.Run(ex+"/"+target, func(t *testing.T) { + t.Parallel() + outDir := filepath.Join(t.TempDir(), "out") + out, code := issue141Run(bin, "build", "--target="+target, filepath.Join(repo, "examples", ex+".howl"), "-o", outDir) + if code != 0 || !strings.Contains(out, "Built") { + t.Fatalf("build rejected: exit %d %s", code, out) + } + if target == "js" { + if _, err := exec.LookPath("node"); err != nil { + t.Skip("node not installed") + } + files, _ := filepath.Glob(filepath.Join(outDir, "*.js")) + if len(files) == 0 { + t.Fatal("no .js output") + } + for _, f := range files { + if b, err := exec.Command("node", "--check", f).CombinedOutput(); err != nil { + t.Fatalf("node --check %s: %s", f, b) + } + } + return + } + mod := "module probe\n\ngo 1.21\n\nrequire github.com/howlcipher/howlframe v0.0.0\n\nreplace github.com/howlcipher/howlframe => " + repo + "\n" + os.WriteFile(filepath.Join(outDir, "go.mod"), []byte(mod), 0o600) + cmd := exec.Command("go", "vet", ".") + cmd.Dir = outDir + cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod") + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("go vet: %s", b) + } + }) + } + } +} diff --git a/required_caps_profile_cli_test.go b/required_caps_profile_cli_test.go index fdc4dbb7..eb9f3965 100644 --- a/required_caps_profile_cli_test.go +++ b/required_caps_profile_cli_test.go @@ -70,7 +70,7 @@ func TestCLIRequiredCapsAndGoverned(t *testing.T) { } // Valid default programs for every excluded registry construct. forms := map[string]string{ - "achieve": `(achieve "goal")`, "confidence": `(print (confidence "test"))`, "db_connect": `(db_connect db "sqlite3" ":memory:")`, "ephemeral_circuit": `(print (ephemeral_circuit (1) "test"))`, "exec": `(print (exec "echo" "x"))`, "lazy_synthesize": `(lazy_synthesize f () "test")`, "llm_generate": `(print (llm_generate "test"))`, "neural_circuit": `(print (neural_circuit (1) "test"))`, "spawn": `(spawn (lambda () (print "x")))`, "spawn_agent": `(spawn_agent "x" (task "test"))`, "sql_query": `(db_connect db "sqlite3" ":memory:") (print (sql_query db "SELECT 1"))`, + "achieve": `(achieve "goal" "constraint")`, "confidence": `(print (confidence "test"))`, "db_connect": `(db_connect db "sqlite3" ":memory:")`, "ephemeral_circuit": `(print (ephemeral_circuit (1) "test"))`, "exec": `(print (exec "echo" "x"))`, "lazy_synthesize": `(lazy_synthesize f () "test")`, "llm_generate": `(print (llm_generate "test"))`, "neural_circuit": `(print (neural_circuit (1) "test"))`, "spawn": `(spawn (lambda () (print "x")))`, "spawn_agent": `(spawn_agent "x" (task "test"))`, "sql_query": `(db_connect db "sqlite3" ":memory:") (print (sql_query db "SELECT 1"))`, } for name, form := range forms { t.Run(name, func(t *testing.T) {