feat: negative/never types — calling a non-function, wrong-arity (jolt-wwy)

Two provably-wrong cases the inference already has the facts for, closing the
last RFC 0006 open question:

- Calling a non-function. At an :invoke whose callee is provably :num or :str
  (the only non-callable types — keywords/maps/vectors/sets are IFn), report
  "cannot call a number as a function". Default level (no closed-world: the
  callee type is inferred at the call site). Covers (5 1), ("hi" 0),
  ((+ 1 2) :k), a let-bound number, and a var holding a number (via vtype-box
  in direct-link). A union is non-callable only when every member is, so
  ((if c 1 :k) x) is accepted (:kw is callable). Verified zero false positives
  on the ray tracer, which calls maps/keywords/vectors as fns throughout.

- Wrong arity to a user fn. The registered single-fixed-arity sig (jolt-zo1)
  makes a mismatched arg count provably throw; reported under the
  JOLT_TYPE_CHECK_USER opt-in (same closed-world boundary; ^:redef/variadic
  skipped). Caught at compile time before the runtime arity error.

Both fold into the existing infer walk, carry :pos for file:line:col, and keep
no-false-positives. Gate green, suite 4718, conformance 335/335, runtime bench
even (compile-time only).
This commit is contained in:
Yogthos 2026-06-13 14:48:14 -04:00
parent e071d09170
commit 328f88636e
3 changed files with 100 additions and 23 deletions

View file

@ -860,7 +860,7 @@
(def ^:private elem-fns #{"rand-nth" "first" "peek" "last" "nth" "fnext" "second"})
;; the checker's emission points, defined after infer but referenced from it
(declare check-invoke check-user-call register-user-fn!)
(declare check-invoke check-user-call register-user-fn! not-callable? type-name)
(defn- var-key [fnode] (str (get fnode :ns) "/" (get fnode :name)))
@ -1044,7 +1044,12 @@
;; everything else: type args, collect the call (var callee), use the
;; declared/estimated return type. range produces a numeric vector.
:else
(let [fnode' (if iscall-var fnode (nth (infer fnode tenv) 1))
(let [fr (when (not iscall-var) (infer fnode tenv))
fnode' (if iscall-var fnode (nth fr 1))
;; the callee's value type: a var's from vtype-box (a fn is
;; :truthy, a def carries its inferred type), else the inferred
;; type of the callee expression (jolt-wwy)
callee-t (if iscall-var (get @vtype-box (var-key fnode)) (nth fr 0))
ares (mapv (fn [a] (infer a tenv)) args)]
(when iscall-var
(swap! calls-box conj [(var-key fnode) (mapv (fn [r] (nth r 0)) ares)]))
@ -1054,6 +1059,11 @@
(when @checking?
(let [ats (mapv (fn [r] (nth r 0)) ares) pos (get node :pos)]
(when cn (check-invoke cn args ats pos))
;; calling a provably non-function (jolt-wwy)
(when (not-callable? callee-t)
(swap! diag-box conj
{:op :call :type (type-name callee-t) :pos pos
:msg (str "cannot call " (type-name callee-t) " as a function")}))
(when (and @strict-box iscall-var)
(let [k (var-key fnode) usig (get @user-sig-box k)]
(when usig (check-user-call k usig ats pos))))))
@ -1128,6 +1138,15 @@
(every? not-seqable? (umembers t))
(or (= t :num) (= t :kw))))
;; concrete non-callable values (jolt-wwy): calling them throws "Cannot call X
;; as a function". Only :num and :str — keywords/maps/vectors/sets are IFn,
;; :truthy/:any/:nil are ambiguous (accepted). A union is non-callable only when
;; every member is.
(defn- not-callable? [t]
(if (union-type? t)
(every? not-callable? (umembers t))
(or (= t :num) (= t :str))))
;; arithmetic / numeric ops: EVERY argument must be a number.
(def ^:private num-ops
#{"+" "-" "*" "/" "inc" "dec" "mod" "rem" "quot" "min" "max" "abs"
@ -1213,11 +1232,13 @@
:params (get ar :params) :body (get ar :body)}))))))))
(defn- check-user-call
"Strict mode: report a call whose concrete argument type provably makes the
callee body throw. For each concrete arg, re-check the body with ONLY that
parameter bound to its arg type (others :any); a diagnostic the all-:any body
did not already have means the argument alone is provably wrong. Monotonic
binding a concrete type can only ADD error-domain hits so no false positive.
"Strict mode: report a call to a registered user fn that provably throws
either a WRONG ARITY (the registered fn has one fixed arity, so a different
arg count always throws, jolt-wwy) or an argument whose concrete type the body
rejects. For the latter, re-check the body with ONLY that parameter bound to
its arg type (others :any); a diagnostic the all-:any body did not already
have means the argument alone is provably wrong. Monotonic binding a
concrete type can only ADD error-domain hits so no false positive.
Cycle-guarded so mutually recursive fns terminate."
[key sig arg-types pos]
(when (not (contains? @checking-box key))
@ -1225,23 +1246,30 @@
(reset! checking-box (conj prev key))
(let [params (:params sig)
body (:body sig)
base (isolated-diag-count body (all-any-env params))
npar (count params)
nargs (count arg-types)
np (if (< npar nargs) npar nargs)]
(reduce
(fn [_ i]
(let [at (nth arg-types i)]
(when (and (not= at :any) (not= at :truthy))
(let [pe (assoc (all-any-env params) (nth params i) at)]
(when (> (isolated-diag-count body pe) base)
(swap! diag-box conj
{:op :user-call :argpos i :type (type-name at) :pos pos
:msg (str "argument " (inc i) " to `" (:name sig)
"` is " (type-name at)
", which its body provably rejects")})))))
nil)
nil (range np)))
nargs (count arg-types)]
(if (not= npar nargs)
;; arity is provably wrong regardless of types — report and stop (the
;; per-arg type re-check would bind params positionally, meaningless
;; under a mismatch)
(swap! diag-box conj
{:op :user-call :type :arity :pos pos
:msg (str "wrong number of args (" nargs ") passed to `"
(:name sig) "` (expected " npar ")")})
(let [base (isolated-diag-count body (all-any-env params))]
(reduce
(fn [_ i]
(let [at (nth arg-types i)]
(when (and (not= at :any) (not= at :truthy))
(let [pe (assoc (all-any-env params) (nth params i) at)]
(when (> (isolated-diag-count body pe) base)
(swap! diag-box conj
{:op :user-call :argpos i :type (type-name at) :pos pos
:msg (str "argument " (inc i) " to `" (:name sig)
"` is " (type-name at)
", which its body provably rejects")})))))
nil)
nil (range npar)))))
(reset! checking-box prev))))
;; --- Inter-procedural driver API (jolt-767) consumed by the back end --------

View file

@ -142,6 +142,26 @@
(os/setenv "JOLT_TYPE_CHECK" nil)
r)
(fn [s] (nil? (string/find "type error" s))))
# negative/never types (jolt-wwy): calling a non-function is reported by default
# in direct-link; wrong-arity to a user fn under the JOLT_TYPE_CHECK_USER opt-in
(def tcn (string (or (os/getenv "TMPDIR") "/tmp") "/jolt-tcneg-" (os/time) ".clj"))
(spit tcn "(ns tcn)\n\n(defn nope []\n (let [n 5] (n 1)))\n")
(check "direct-link reports calling a number as a function"
(do (os/setenv "JOLT_DIRECT_LINK" "1")
(def r (run-err tcn))
(os/setenv "JOLT_DIRECT_LINK" nil)
r)
(has "cannot call a number as a function"))
(def tca (string (or (os/getenv "TMPDIR") "/tmp") "/jolt-tcarity-" (os/time) ".clj"))
(spit tca "(ns tca)\n\n(defn f [x y] (+ x y))\n(defn g [] (f 1))\n")
(check "JOLT_TYPE_CHECK_USER reports wrong arity to a user fn"
(do (os/setenv "JOLT_DIRECT_LINK" "1")
(os/setenv "JOLT_TYPE_CHECK_USER" "1")
(def r (run-err tca))
(os/setenv "JOLT_DIRECT_LINK" nil)
(os/setenv "JOLT_TYPE_CHECK_USER" nil)
r)
(has "wrong number of args (1) passed to `f` (expected 2)"))
(if (> fails 0)
(error (string "cli-test: " fails " failing check(s)"))

View file

@ -45,6 +45,23 @@
(assert (= 0 (nd "(inc (count [1 2 3]))")) "count of vector + inc of :num both fine")
(assert (= 0 (nd "(inc (first [1 2 3]))")) "first of vector -> :num, inc fine")
# --- calling a non-function (jolt-wwy): :num and :str are not callable --------
(assert (= 1 (nd "(5 1)")) "calling a number is reported")
(assert (= 1 (nd "(\"hi\" 0)")) "calling a string is reported")
(assert (= 1 (nd "((+ 1 2) :k)")) "calling an arithmetic result (a :num) is reported")
(assert (= 1 (nd "(let [n 5] (n 1))")) "calling a let-bound number is reported")
(assert (= 1 (nd "(let [s \"x\"] (s 0))")) "calling a let-bound string is reported")
# (a var holding a number, e.g. (def nn 5) (nn 1), is caught in direct-link
# mode via vtype-box; the standalone checker has no var value types)
# callable values: keyword/map/vector/set as IFn — NOT reported
(assert (= 0 (nd "(:k {:k 1})")) "keyword call is fine")
(assert (= 0 (nd "({:a 1} :a)")) "map call is fine")
(assert (= 0 (nd "([10 20] 1)")) "vector call is fine")
(assert (= 0 (nd "(#{1 2} 1)")) "set call is fine")
(assert (= 0 (nd "(fn [c] ((if c 1 :k) 0))")) "union {:num | :kw} callee accepted (:kw is callable)")
(assert (= 0 (nd "(fn [f] (f 1))")) "calling an unknown (:any) param accepted")
(assert (= 1 (nd "(fn [c] ((if c 1 \"x\") 0))")) "union {:num | :str} callee — both non-callable — reported")
# --- bounded unions (jolt-pz5): report only when EVERY member is in the error
# domain; accept when any member is valid. Differing branches used to collapse
# to :any (accepted); now they form {:union #{...}} and are checked per-member.
@ -85,6 +102,18 @@
"strict: a ^:redef fn is not a stable requirement, not reported")
(assert (= 1 (nds "(do (defn ufrec [x] (ufrec (+ x 1))) (ufrec \"s\"))"))
"strict: self-recursion terminates (cycle guard) and the (+ x 1) on a string is reported once")
# wrong arity to a user fn (jolt-wwy), strict mode: the registered fixed arity
# makes a mismatched call provably throw, regardless of argument types
(assert (= 1 (nds "(do (defn uar [x y] (+ x y)) (uar 1))"))
"strict: 2-arg fn called with 1 arg is reported")
(assert (= 1 (nds "(do (defn uar2 [x] x) (uar2 1 2 3))"))
"strict: 1-arg fn called with 3 args is reported")
(assert (= 0 (nds "(do (defn uar3 [x y] (+ x y)) (uar3 1 2))"))
"strict: correct arity accepted")
(assert (= 0 (nd "(do (defn uar4 [x y] (+ x y)) (uar4 1))"))
"default level does NOT report user-fn arity (closed-world, opt-in)")
(assert (= 0 (nds "(do (defn ^:redef uar5 [x y] (+ x y)) (uar5 1))"))
"strict: ^:redef fn arity not checked (could be redefined)")
# --- the diagnostic carries op + type + a message ----------------------------
(def one (in (diags "(inc \"x\")") 0))