Skip to content

Table-driven arity check + Go/JS false-Built sweep (fixes #141) - #142

Merged
howlcipher merged 2 commits into
mainfrom
issue141-arity
Oct 12, 2026
Merged

howlcipher merged 2 commits into
mainfrom
issue141-arity

Conversation

@howlcipher

@howlcipher howlcipher commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

Fixes #141

Summary

  1. Table-driven arity check. internal/construct/arity.go declares one arity for every construct-registry entry: 136 have an arity, 18 are exempt with a reason, out of 154 entries. A drift test (internal/construct/arity_test.go) makes any new built-in declare an arity.
    • construct.EnforceArity runs before every ast.Desugar site in the CLI, so it covers bytecode, HFIR, the interpreter, check, Go and JS.
    • A wrong arity reports ARITY_ERROR: read_file expects 1 arg, got 0 and exits 1, with no panic and no artifact, under both the default and governed profiles.
    • The bytecode compiler keeps the table as a backstop. The Governed build of (fetch "http://x") crashes the bytecode compiler #139 fetch check has moved onto the table (fetch is {2,3}).
    • The bytecode and/or now fails closed on an unfolded n-ary node instead of silently dropping operands.
  2. Tests.
    • Motoko's full repro list.
    • A too-few and a too-many case for every table entry, under both profiles.
    • A fuzz test: arities 0..N for all 154 heads across bytecode (default and governed), -compile-hfir-bc, Go and JS, about 4.3k runs, asserting no panic. -run is used only at rejected arities, so nothing executes.
    • The fuzz test found and fixed these panics:
      • Empty export/module (checker).
      • On Go: trace, fuzzy_cast, semantic_match, assert_semantic, and web_app with no port or non-list handlers. Their shape checks had been commented out.
  3. Go/JS: never a false "Built". A let/do/if/for/while used as a value on --target=go|js is now rejected with UNSUPPORTED_ON_TARGET.
    • This covers all/any, ver_gt/ver_ge, not, starts_with/ends_with, list_contains, map_has, value if/cond, and list_get/parse_json on a computed operand.
    • Go time_now in an expression emitted nothing; it now emits Unix seconds.
    • TestIssue141TargetNeverFalseBuilt checks 31 probes per target: each build is either rejected or passes go vet / node --check.

Journal: docs/journals/2026-10-12_issue141_arity_target_sweep.md

Caveats

  • The optional fetch-body fail-closed is not done. It breaks the locked TestProdFlipCriteriaLock/fetch_body and the nested_multi_effect_* ABI conformance cases, which pin the "body skipped" behavior. That is a flip-criteria decision.
  • The value-position guard is conservative. wasm_app is a statement form (review fix; TestIssue141WasmExamplesBuildOnGoJS builds the three wasm examples on Go and JS and checks they compile).
  • Arity covers head symbols only; sub-form shapes are still checked by each backend.
  • No -compile-bc/HFIR flip, Write flagged http_server bytecode and stop #90 untouched, no tip-lock, no live LLM, no DOM, no new language features, no token claims.

Verification

gofmt -l is empty. go build/vet/test ./... and go test ./experiments/... pass. All three run.sh pass: v0 60/60 ×4 languages, heldout-v0 howl 30/30 and others 32/32, heldout-v1 34/34 ×4.

@howlcipher howlcipher left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dev-lead review (Motoko seat), COMMENT only

Head 4aaf9c8f21af1a61e84acd3273242b137c28b81d, base aef976ba. Verified locally in a scratch checkout. Base and head binaries were built side by side.

Verdict: not merge-ready yet. There is one small blocker: a Go/JS guard regression on wasm_app, described below. Everything else checks out.

Blocker: the guard rejects 3 working wasm_app examples on Go and JS

targetStatementForms lists cli_app and web_app but leaves out wasm_app. Because of that, the top-level children of wasm_app fall into the default branch and are walked as values. Every top-level if/do is then rejected.

example base go base js head
examples/wasm_app_hello.howl Built; go build OK; ran Built; node runs clean UNSUPPORTED_ON_TARGET on both
examples/wasm_app_print.howl Built; go build OK; ran Built; node prints 42 UNSUPPORTED_ON_TARGET on both
examples/wasm_math.howl Built; go build OK; ran Built; node runs clean UNSUPPORTED_ON_TARGET on both

Fix: add "wasm_app": true to targetStatementForms and add a regression test that builds these three examples for --target=go and --target=js.

Q(a): is the conservative Go/JS guard acceptable?

Yes, once wasm_app is fixed. I built every tracked .howl file for go and js with both binaries. 168 builds succeeded on base and are rejected on head. I then compiled each base artifact: go build with a replace to the base module, node --check for JS.

  • 162 / 168 base artifacts were already broken. The 81 Go artifacts had syntax errors such as unexpected { or unexpected keyword if. The 81 JS artifacts failed with errors such as Unexpected identifier '__pj1' or Unexpected token. Rejecting these turns a false "Built" into an honest error, which is the whole point.
  • 6 / 168 compiled and ran. All six are the wasm_app cases above, so they are the only real regression.
  • On head, all, any, ver_gt and if-as-value all exit 1 on both go and js. None print Built, none leave an artifact, and all give UNSUPPORTED_ON_TARGET (malformed all/any gets its own shape error first).

Q(b): should check reject wrong arity?

Yes. Failing earlier is better. Across 316 tracked .howl files × {default, governed} (632 runs), check gave the same exit code on base and head with 0 differences, so it produces no false positives on examples, stdlib, apps or the three benchmark corpora. The error is the usual JSON shape with line and column (for example {"reason":"ARITY_ERROR: read_file expects 1 arg, got 0","line":1,"column":11}), so the LSP and other diagnostic consumers get a position. The one test change, 1-arg achieve → 2-arg, is legitimate. Base build already rejected 1-arg achieve with achieve expects (achieve target constraint), so only check was lax before.

Re-test of the #140 audit list

I ran all 29 cases with both check and build: governed read_file 0, write_file 0/1, mkdir 0, env 0, task 0, res 0/1/2, res_json 0/1, store_open 0, defun 0/1, http_server 0, (print (str_trim)); default exec, spawn_agent 0/1, llm_generate, sql_query 0/1, db_connect 0/1/2, lazy_synthesize 0/1/2, confidence. All 58 runs gave a deterministic ARITY_ERROR: <name> expects N arg(s), got M with a line and column, exit 1, no artifact and no panic. Nothing still panics.

Arity table and drift test

  • The table is keyed by registry name. The drift test checks that every construct.Table() entry has exactly one of arity or exemption (with a reason), and it rejects stale entries in either map. Probe: I temporarily added a zz_fake registry entry, and the test failed with zz_fake has no arity; add it to arities…. I reverted it afterwards.
  • Small count mismatch: the test logs registry=154 arities=136 exempt=18, but the PR text and journal say 134/20. Please fix the text (non-blocking).
  • Exemptions (18): all are legitimate. They are Unsupported constructs that the construct gate rejects or the Go backend shape-checks (assert_semantic, fuzzy_cast, semantic_match, trace, middleware, schema, struct, test, match, import, go_import), forms consumed before the check runs (include, use, patch, with_context), and pure annotations (type_hint, type_hints, type_param). Nothing that should have an arity is exempted.
  • Spot checks all still pass: fetch with 2 or 3 args (optional body), str_slice with 2 or 3, cli_args with 0, llm_generate with 2, variadic exec/str, n-ary and, let, and time_now with 0.
  • The bytecode backstop in compileNode uses the same table. The new and/or unfolded-operand guard is a good fail-closed addition.

Fuzz test

TestIssue141ArityFuzzNoPanic enumerates arities 0..N for every registry entry. It uses no RNG, so it is deterministic by construction, and it took 2.25 s locally, which is fine for CI. The panic fixes are real: the export/module arity entries, and in gogen the ReportError calls are un-commented (it prints JSON and runs os.Exit(1)). The "LIST" → "List" typo fix in semantic_match is also real. No recover() was added. The only recover in gogen (line ~1260) predates this PR.

fetch body caveat

Confirmed. prod_flip_criteria_test.go, subtest fetch body, asserts that the artifact does not contain the body string, and OpFetch pops 2. The PR doesn't touch that file. Silently dropping a PUT/POST body is a correctness bug, so please file it as an issue. Fixing it would mean an owner-approved change to the lock or opcode. It must not be done inside a #141-style PR.

time_now on Go

(print (time_now)) on Go now emits int(time.Now().Unix()) (Unix seconds), which matches the VM and JS. Before, it produced an empty expression. Looks good.

Scope, hard nos, CI

  • 10 files: the journal, howlframe.go, gogen, bytecode, construct/arity.go and its test, two new CLI tests, and two single-line test updates. No benchmark data, docs fence or flip files are touched.
  • Hard nos are intact. There is no prod -compile-bc/HFIR default flip, TestProdFlipCriteriaLock is unedited (0-line diff), #90 is untouched, and the PR makes no live-LLM or H1 claims.
  • CI build: success on 4aaf9c8f. gofmt -l is clean. Local go test ./...: exit 0.

To merge

  1. Add wasm_app to targetStatementForms and add a test that the three wasm examples still build on go and js.
  2. (Nit) Correct the 134/20 counts to 136/18 in the PR body and journal.

Once (1) lands and CI is green, Okabe may undraft and squash-merge. File the fetch-body issue separately.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bytecode compiler panics on wrong arity for many built-ins

1 participant