From 328f88636e43756dca89e0e28ddec4fea5fa80a3 Mon Sep 17 00:00:00 2001 From: Yogthos Date: Sat, 13 Jun 2026 14:48:14 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20negative/never=20types=20=E2=80=94=20ca?= =?UTF-8?q?lling=20a=20non-function,=20wrong-arity=20(jolt-wwy)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- jolt-core/jolt/passes.clj | 74 ++++++++++++++++++-------- test/integration/cli-test.janet | 20 +++++++ test/integration/type-check-test.janet | 29 ++++++++++ 3 files changed, 100 insertions(+), 23 deletions(-) diff --git a/jolt-core/jolt/passes.clj b/jolt-core/jolt/passes.clj index 7b69114..fc6199b 100644 --- a/jolt-core/jolt/passes.clj +++ b/jolt-core/jolt/passes.clj @@ -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 -------- diff --git a/test/integration/cli-test.janet b/test/integration/cli-test.janet index d45e96d..184d1b9 100644 --- a/test/integration/cli-test.janet +++ b/test/integration/cli-test.janet @@ -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)")) diff --git a/test/integration/type-check-test.janet b/test/integration/type-check-test.janet index 28b5088..5580a24 100644 --- a/test/integration/type-check-test.janet +++ b/test/integration/type-check-test.janet @@ -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))