sx: step 8 — non-exhaustive match warnings

Emit a warning when a `match` expression on an ADT value misses one
or more constructors and lacks an `else`/`_` clause. Behaviour is
non-fatal — the match still runs, the warning goes to stderr.

- spec/evaluator.sx: helpers `match-clause-is-else?`, `match-clause-ctor-name`,
  `match-warn-non-exhaustive`, `match-check-exhaustiveness`. The latter
  reads the `*adt-registry*` (already populated by `define-type`),
  collects constructor patterns from clauses, and dedupes via an
  `*adt-warned*` env-bound dict so each (type, missing-set) warns once.
  Wired into `step-sf-match` via a `do` block before clause dispatch.

- hosts/javascript/platform.py: `host-warn` primitive (`console.warn`)
  + matching `hostWarn` js-id helper so the JS-transpiled spec code
  can call it directly. Spec code reaches JS via `sx_build target=js`.

- hosts/ocaml/lib/sx_runtime.ml + sx_primitives.ml: `host-warn` runtime
  helper (`prerr_endline`) and registered primitive.

- hosts/ocaml/lib/sx_ref.ml: HAND-PATCHED. `step_sf_match` now calls
  a hand-written `match_check_exhaustiveness` that handles both
  `AdtValue` and back-compat dict-shape ADT values. The OCaml side
  is *not* retranspiled because regenerating sx_ref.ml drops
  several preamble fixes (seq_to_list, string->symbol mangling,
  empty-dict literal bug). Future retranspile must reapply this patch.

- spec/tests/test-adt.sx: 5 new tests covering exhaustive,
  non-exhaustive (warning is non-fatal), `else` suppression,
  partial coverage with one missing constructor, and `_` wildcard
  suppression. Tests assert return values only — warnings go to
  stderr and are not captured.

Warning format: `[sx] match: non-exhaustive — TypeName: missing Ctor1, Ctor2`
Both hosts emit identical messages.

Tests: OCaml 4540 → 4545 (+5), JS 2586 → 2591 (+5). Zero regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 00:13:41 +00:00
parent 7b050fb217
commit 6d39111992
8 changed files with 281 additions and 15 deletions

View File

@@ -2903,6 +2903,81 @@
pairs)))
:else (= pattern value))))
(define
match-clause-is-else?
(fn
(clause)
(let
((p (first clause)))
(or
(= p (quote _))
(= p (quote else))
(= p :else)))))
(define
match-clause-ctor-name
(fn
(clause)
(let
((p (first clause)))
(cond
(and (list? p) (not (empty? p)) (symbol? (first p)))
(symbol-name (first p))
(and (symbol? p) (not (= p (quote _))) (not (= p (quote else))))
nil
:else nil))))
(define
match-warn-non-exhaustive
(fn
(env type-name registered clause-ctors)
(let
((missing
(filter (fn (c) (not (contains? clause-ctors c))) registered)))
(when
(not (empty? missing))
(do
(when
(not (env-has? env "*adt-warned*"))
(env-bind! env "*adt-warned*" (dict)))
(let
((warned (env-get env "*adt-warned*"))
(key (str type-name "|" (join "," missing))))
(when
(not (get warned key))
(do
(dict-set! warned key true)
(host-warn
(str
"[sx] match: non-exhaustive — "
type-name
": missing "
(join ", " missing))))))))
nil)))
(define
match-check-exhaustiveness
(fn
(val clauses env)
(when
(and (dict? val) (get val :_adt))
(let
((type-name (get val :_type)))
(when
(and (env-has? env "*adt-registry*") type-name)
(let
((registered
(get (env-get env "*adt-registry*") type-name)))
(when
(and registered (not (some match-clause-is-else? clauses)))
(let
((clause-ctors
(filter
(fn (n) (not (nil? n)))
(map match-clause-ctor-name clauses))))
(match-warn-non-exhaustive
env type-name registered clause-ctors)))))))))
(define
step-sf-match
(fn
@@ -2910,15 +2985,17 @@
(let
((val (trampoline (eval-expr (first args) env)))
(clauses (rest args)))
(let
((result (match-find-clause val clauses env)))
(if
(nil? result)
(make-cek-value
(str "match: no clause matched " (inspect val))
env
(kont-push (make-raise-eval-frame env false) kont))
(make-cek-state (nth result 1) (first result) kont))))))
(do
(match-check-exhaustiveness val clauses env)
(let
((result (match-find-clause val clauses env)))
(if
(nil? result)
(make-cek-value
(str "match: no clause matched " (inspect val))
env
(kont-push (make-raise-eval-frame env false) kont))
(make-cek-state (nth result 1) (first result) kont)))))))
(define
step-sf-handler-bind

View File

@@ -454,4 +454,33 @@
(match
(WX (LeftX 9))
((WX (LeftX v)) (str "left-" v))
((WX (RightX v)) (str "right-" v)))))))
((WX (RightX v)) (str "right-" v))))))
(deftest
"exhaustive match runs without error"
(do
(define-type ExA1 (CaA1 v) (CbA1))
(assert= 1 (match (CaA1 1) ((CaA1 x) x) ((CbA1) 0)))
(assert= 0 (match (CbA1) ((CaA1 x) x) ((CbA1) 0)))))
(deftest
"non-exhaustive match still returns value (warning is non-fatal)"
(do
(define-type ExA2 (CaA2 v) (CbA2))
(assert= 9 (match (CaA2 9) ((CaA2 x) x)))))
(deftest
"match with else clause suppresses non-exhaustive warning"
(do
(define-type ExA3 (CaA3 v) (CbA3) (CcA3))
(assert= "a" (match (CaA3 1) ((CaA3 x) "a") (else "other")))
(assert= "other" (match (CbA3) ((CaA3 x) "a") (else "other")))))
(deftest
"match with all-but-one constructor still runs"
(do
(define-type ExA4 (CaA4 v) (CbA4) (CcA4))
(assert= 5 (match (CaA4 5) ((CaA4 x) x) ((CbA4) 0)))
(assert= 0 (match (CbA4) ((CaA4 x) x) ((CbA4) 0)))))
(deftest
"match wildcard pattern suppresses non-exhaustive warning"
(do
(define-type ExA5 (CaA5 v) (CbA5))
(assert= 7 (match (CaA5 7) ((CaA5 x) x) (_ 0)))
(assert= 0 (match (CbA5) ((CaA5 x) x) (_ 0))))))