feat(obs): reject nullable call arguments for non-nullable parameters

Compiled callees register a per-parameter nullable mask (self included for methods, specializations included); cross-module callees fall back to their vpi param annotations. A nullable binding/field argument now fails instead of silently narrowing.
This commit is contained in:
TermiNexus committed 2026-11-03 18:12:20 +08:00
1 parent b17de2bb65
commit 65e03c823f
6 files changed
+156

No files matched your search

+16
View File
@@ -1664,6 +1664,22 @@ def _vpi_param_annot_seg_is_ptr(prm_annots: str, seg_idx: int) -> int:
return 0
# _vpi_param_annot_seg_has_nullable - 1 when the segment is a nullable type (`T?`).
def _vpi_param_annot_seg_has_nullable(prm_annots: str, seg_idx: int) -> int:
"""Return 1 if the param-annotation segment contains '?' (nullable)."""
if prm_annots is None or seg_idx < 0: return 0
cur: sizet = 0
for seg in range(int(seg_idx)):
while prm_annots[cur] != '\0' and prm_annots[cur] != '@': cur += 1
if prm_annots[cur] == '\0': return 0
cur += 1
slen: sizet = 0
while prm_annots[cur + slen] != '\0' and prm_annots[cur + slen] != '@':
if prm_annots[cur + slen] == '?': return 1
slen += 1
return 0
# _ensure_vpi_param_annot_loaded - lazily load a module's vpi stub param-annotation cache.
# Mirror of the return variant; reads the .vpi file on miss, idempotent.
def _ensure_vpi_param_annot_loaded(src_sha1: str, temp_dir: str) -> int:
+67
View File
@@ -101,6 +101,11 @@ novirtual class CallGenBase:
# (PermPool-persistent; index = emitted parameter index, self included for methods).
AutoAddrModes: hashtable.HashTable* = None
# --- nullable parameter flags (strict OBS): key = callee symbol name -> byte-per-parameter
# '0'/'1' (1 = the parameter is declared nullable `T?`); index = emitted parameter index,
# self included for methods. Registered when a callee definition is translated.
NullableParams: hashtable.HashTable* = None
def InitCallGen(self) -> int:
"""Initialize the call mixin fields (inlined by subclass constructors since NoVTable does not forward inherited symbols)"""
if self is None: return 1
@@ -140,6 +145,68 @@ novirtual class CallGenBase:
if code == 50: return 2 # '2' = temp
return 0
# _register_nullable_params - store per-parameter nullability flags for a callee symbol name.
def _register_nullable_params(self, name: char*, flags: char*, count: int) -> int:
"""Store per-parameter '0'/'1' nullable flags (PermPool-persistent) keyed by callee symbol name."""
if name is None or flags is None or count <= 0: return 0
perm: memhub.MemBuddy* = CompilerFactory.factory.PermPool
if perm is None: return 0
if self.NullableParams is None:
self.NullableParams = hashtable.HashTable(perm)
if self.NullableParams is None: return 0
key: char* = perm.alloc(string.strlen(name) + 1)
if key is None: return 0
string.strcpy(key, name)
val: char* = perm.alloc(sizet(count) + 1)
if val is None: return 0
string.memcpy(val, flags, sizet(count))
val[count] = char(0)
self.NullableParams[key] = val
return 1
# _register_nullable_from_args - build and store the nullable flags from a callee's AST args.
def _register_nullable_from_args(self, pool: memhub.MemBuddy*, name: char*,
alist: list[ast.AST*]*, from_idx: int) -> int:
"""Register which parameters (from from_idx on) are declared nullable; 1 on success."""
if pool is None or name is None or alist is None: return 0
n: sizet = len(alist)
if n <= 0: return 0
buf: str = pool.alloc(n + 2)
if buf is None: return 0
cnt: int = 0
i: int = from_idx
if i < 0: i = 0
while i < int(n):
a: ast.AST* = alist.get(sizet(i))
i += 1
flag: char = char(48)
if a is not None and a.kind() == ast.ASTKind.Arg:
ag: ast.Arg* = ast.Arg*(a)
if ag.annotation is not None and GenApi.annotation_is_nullable(ag.annotation) != 0:
flag = char(49)
buf[cnt] = flag
cnt += 1
buf[cnt] = char(0)
return self._register_nullable_params(name, buf, cnt)
# _param_nullable_known - 1 when a nullability mask was registered for the callee symbol.
def _param_nullable_known(self, name: char*) -> int:
if self.NullableParams is None or name is None: return 0
v: rawptr = self.NullableParams.__getitem__(name)
if v is None: return 0
return 1
# _param_is_nullable - 1 when parameter idx of the callee symbol is declared nullable.
def _param_is_nullable(self, name: char*, idx: int) -> int:
if self.NullableParams is None or name is None or idx < 0: return 0
v: rawptr = self.NullableParams.__getitem__(name)
if v is None: return 0
flags: char* = rawptr(v)
i: int = 0
while i < idx and flags[i] != char(0): i += 1
if i != idx: return 0
return 1 if int(flags[idx]) == 49 else 0
# translate_call_arg - translate one call argument, applying ref/temp auto-address when required.
def translate_call_arg(self, builder: mlirlite.IRBuilder*, pool: memhub.MemBuddy*, mod: mlirlite.LLVMModule*,
trans: HT.Translator*, callee_name: char*, arg_idx: int, arg_node: ast.AST*,
+25
View File
@@ -179,6 +179,31 @@ novirtual class CallGenLib(CallGenBase.CallGenBase):
# ensure-load + direct cache lookup (not _vpi_method_param_annot, which misses module funcs)
if self.Gen.CallGenBase.TempDir is not None and self.Gen.CallGenBase.TempDirLen > 0: VpiReader._ensure_vpi_param_annot_loaded(sha1_buf, self.Gen.CallGenBase.TempDir)
prm_annots: char* = VpiReader._lookup_vpi_param_annots(sha1_buf, cls_nm, meth_nm)
# Strict OBS: a nullable binding/field may be None and must not flow into a non-nullable
# parameter. Same-module callees use the nullable registry, cross-module ones use their
# vpi param annotations; an unknown callee signature is left alone.
if trans is not None and trans.StrictOBS != 0 and cargs is not None:
arg_n_chk: int = int(len(cargs))
ai_chk: int = 0
while ai_chk < arg_n_chk:
an_chk: ast.AST* = cargs.get(sizet(ai_chk))
ai_chk += 1
if an_chk is None: continue
if self.Gen.ExprGenAttr._base_is_nullable(pool, trans, an_chk) == 0: continue
pidx_chk: int = ai_chk - 1 + self_off
checked_chk: int = 0
allow_chk: int = 0
if self.Gen.CallGenBase._param_nullable_known(call_name) != 0:
checked_chk = 1
if self.Gen.CallGenBase._param_is_nullable(call_name, pidx_chk) != 0: allow_chk = 1
elif prm_annots is not None:
checked_chk = 1
if VpiReader._vpi_param_annot_seg_has_nullable(prm_annots, pidx_chk) != 0: allow_chk = 1
if checked_chk == 0: continue
if allow_chk == 0:
pf_chk: int = GenApi.sha1_prefix_len(call_name)
short_chk: char* = call_name + pf_chk if pf_chk != 0 else call_name
self.Gen.TypeGenErr.fatal_error(an_chk, f"parameter {ai_chk} of '{short_chk}' is not nullable: a nullable value may be None; declare it as `T?` or unwrap it with `match` first", "TypeError")
# generic-specialized class: annotations live on the template class, so fall back to template lookup
tpl_src: int = 0
if prm_annots is None:
+16
View File
@@ -8,6 +8,7 @@ import viperlib
import lib.core.VLogger as VLogger
import lib.core.Gen.Translator as HT
import lib.core.CompilerFactory as CompilerFactory
import lib.core.Gen.GenApi as GenApi
from . import ClassGenBase
import lib.Stub.StubMerger.StubMergerStore as StubMergerStore
@@ -547,6 +548,21 @@ novirtual class ClassGenGeneric(ClassGenBase.ClassGenBase):
mode_cnt_g += 1
mode_buf_g[mode_cnt_g] = char(0)
if has_ref_g != 0: self.Gen.CallGenBase._register_auto_addr_modes(spec_name, mode_buf_g, mode_cnt_g)
# Per-parameter nullability (strict OBS), aligned with the modes above.
nul_buf_g_: str = pool.alloc(an + 1)
if nul_buf_g_ is not None:
nc_g: int = 0
for ai_ng in range(int(an)):
pa_ng: ast.AST* = alist.get(ai_ng)
f_g: char = char(48)
if pa_ng is not None and pa_ng.kind() == ast.ASTKind.Arg:
arg_ng: ast.Arg* = ast.Arg*(pa_ng)
if arg_ng.annotation is not None and GenApi.annotation_is_nullable(arg_ng.annotation) != 0:
f_g = char(49)
nul_buf_g_[nc_g] = f_g
nc_g += 1
nul_buf_g_[nc_g] = char(0)
self.Gen.CallGenBase._register_nullable_params(spec_name, nul_buf_g_, nc_g)
if tpl_file is not None: self.Gen.TypeGenResolve.set_current_file(saved_cur)
trans.ModuleSha1 = saved_ms
trans._imported_modules = saved_imp
+17
View File
@@ -724,6 +724,23 @@ novirtual class ClassGenVTable(ClassGenBase.ClassGenBase):
mode_cnt += 1
mode_buf[mode_cnt] = char(0)
if has_ref_m != 0: self.Gen.CallGenBase._register_auto_addr_modes(mangled_name, mode_buf, mode_cnt)
# Per-parameter nullability (strict OBS), same emitted-parameter indexing.
nul_buf_m: str = pool.alloc(an + 1)
if nul_buf_m is not None:
nc_m: int = 0
if is_static_m == 0:
nul_buf_m[nc_m] = char(48)
nc_m += 1
for ai_n in range(int(start_idx), int(an)):
arg_n: ast.Arg* = ast.Arg*(alist.get(ai_n))
f_m: char = char(48)
if arg_n is not None and arg_n.annotation is not None \
and GenApi.annotation_is_nullable(arg_n.annotation) != 0:
f_m = char(49)
nul_buf_m[nc_m] = f_m
nc_m += 1
nul_buf_m[nc_m] = char(0)
self.Gen.CallGenBase._register_nullable_params(mangled_name, nul_buf_m, nc_m)
# *args variadic: set IsVarArg=1 so the signature carries the ... suffix (the body
# pipeline allocates the va_list buffer, same as a module-level function).
+15
View File
@@ -56,6 +56,21 @@ novirtual class FuncGenDef(CallGenBase.CallGenBase):
buf[cnt] = char(0)
if has_ref != 0: self.Gen.CallGenBase._register_auto_addr_modes(name, buf, cnt)
stdlib.free(buf)
# Per-parameter nullability (strict OBS): same emitted-parameter indexing as the modes above.
nul_buf: bytes = stdlib.malloc(an + 1)
if nul_buf is not None:
nc: int = 0
for i2 in range(int(an)):
arg2: ast.Arg* = ast.Arg*(alist.get(i2))
if arg2 is None or arg2.arg is None: continue
if arg2.annotation is not None and self.Gen.TypeGenResolve._is_builtin_marker(arg2.annotation, "CVoid", imp) != 0: continue
f2: char = char(48)
if arg2.annotation is not None and GenApi.annotation_is_nullable(arg2.annotation) != 0: f2 = char(49)
nul_buf[nc] = f2
nc += 1
nul_buf[nc] = char(0)
self.Gen.CallGenBase._register_nullable_params(name, nul_buf, nc)
stdlib.free(nul_buf)
return 1
# ============================================================