From 4900c12ca87e8c615e5da22dcc9462b3ce8b0a60 Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:27:24 +0000 Subject: [PATCH 1/3] feat(lower): TypeVar/Generic annotation erasure with per-call-site monomorphization - T = TypeVar('T') declarations lower as no-ops; annotation uses of T erase to the declared bound or, when unbound, an internal typevar placeholder resolved at call sites. - Variance keywords (covariant=/contravariant=) are ignored. - Generic[T] bases are erased; the class lowers as if unparametrized and records the declared typevars for specialization. - Calls to generic functions unify argument types with the declared typevars and emit a specialized copy per call site (ident.int). - Construction/subscripts of user generic classes (Box[int], Box(1)) emit specialized records per instantiated type set (Box_int); builtin aliases (list[int]) are untouched. - Params specializing to none drop from the emitted signature and bind as constants; class instantiation at none fences PS1003. - Stays fenced: TypeVar/generic fn used as a value (PS2002), TypeVar needing a str name or unsupported keywords (PS2003), unresolved typevar locals (PS1001), inference conflicts and missing instantiations (PS1003), isinstance on generic aliases. --- src/pyscriptc/lower/__init__.py | 127 ++++++++++++++++++- src/pyscriptc/lower/_calls.py | 184 ++++++++++++++++++++++++++- src/pyscriptc/lower/_classes.py | 209 +++++++++++++++++++++++++++++-- src/pyscriptc/lower/_closures.py | 2 +- src/pyscriptc/lower/_core.py | 57 +++++++++ src/pyscriptc/lower/_expr.py | 4 + src/pyscriptc/lower/_stdlib.py | 4 + src/pyscriptc/lower/_stmt.py | 17 ++- 8 files changed, 585 insertions(+), 19 deletions(-) diff --git a/src/pyscriptc/lower/__init__.py b/src/pyscriptc/lower/__init__.py index f675f828..4c0a11e2 100644 --- a/src/pyscriptc/lower/__init__.py +++ b/src/pyscriptc/lower/__init__.py @@ -31,9 +31,11 @@ _const_none, _declared_globals, _dotted, + _has_typevar, _is_none_expr, _name, _show, + _subst_type, _tuple_assign, ) from pyscriptc.lower._expr import ExprMixin @@ -138,6 +140,25 @@ def __init__(self, filename: str, source: str) -> None: self.opt_unwrap: dict[str, dict[str, Any]] = {} # User exception classes take codes after the builtin table (1–8). self.exc_n = USER_EXC_BASE + # TypeVar erasure (tier-2): name -> bound type or None when unbound. + # Annotations on an unbound TypeVar produce {"kind": "typevar"} + # placeholders that only ever live in signatures/field tables; + # call sites monomorphize them into concrete instantiations. + self.typevars: dict[str, dict[str, Any] | None] = {} + # Active substitution while lowering one instantiation. + self.tv_subst: dict[str, dict[str, Any]] = {} + # Functions whose signature carries placeholders, name -> def. + self.generic_fns: dict[str, ast.FunctionDef] = {} + # (fn, ((typevar, type_key), ...)) -> emitted name, plus the queue + # of instantiations still to lower (fn, emitted name, mapping). + self.inst_fns: dict[tuple[str, tuple[tuple[str, str], ...]], str] = {} + self.inst_queue: list[tuple[str, str, dict[str, dict[str, Any]]]] = [] + # (class, (type_key, ...)) -> emitted class name, plus queued + # (base ClassDef, emitted name, mapping) class instantiations. + self.class_insts: dict[tuple[str, tuple[str, ...]], str] = {} + self.class_queue: list[tuple[ast.ClassDef, str, dict[str, dict[str, Any]]]] = [] + # The substituted (params, ret) while an instantiation lowers. + self.inst_sig: tuple[list[dict[str, Any]], dict[str, Any]] | None = None def fail(self, node: ast.AST, code: str, message: str) -> NoReturn: raise Fence(code, message, span_of(node, self.filename), self.function) @@ -194,6 +215,10 @@ def module(self, tree: ast.Module) -> dict[str, Any]: self.sigs[fn.name] = self._signature(fn, None, kind) except Fence as exc: fences.append(exc) + else: + params, ret = self.sigs[fn.name] + if _has_typevar(ret) or any(_has_typevar(param["type"]) for param in params): + self.generic_fns[fn.name] = fn if self.deferred_module: self._bind_deferred_module(fences) if self.cataloguing: @@ -210,7 +235,8 @@ def module(self, tree: ast.Module) -> dict[str, Any]: } lowered_classes: list[dict[str, Any]] = [] for node in class_nodes: - if node.name not in self.classes or self.classes[node.name].get("synthetic"): + info = self.classes.get(node.name) + if info is None or info.get("synthetic") or info.get("typevars"): continue self.function = node.name self.caught = [] @@ -226,7 +252,7 @@ def module(self, tree: ast.Module) -> dict[str, Any]: lowered_classes.extend(self._synthetic_classes({item["name"] for item in lowered_classes})) lowered: list[dict[str, Any]] = [] for fn in functions: - if fn.name not in self.sigs: + if fn.name not in self.sigs or fn.name in self.generic_fns: continue self.function = fn.name self.temp_n = 0 @@ -246,6 +272,7 @@ def module(self, tree: ast.Module) -> dict[str, Any]: saved_bindings = dict(self.bindings) saved_aliases = dict(self.type_aliases) saved_declared = set(self.declared) + saved_typevars = dict(self.typevars) try: body = self._body(list(fn.body)) if self.caught: @@ -274,6 +301,8 @@ def module(self, tree: ast.Module) -> dict[str, Any]: self.bindings = saved_bindings self.type_aliases = saved_aliases self.declared = saved_declared + self.typevars = saved_typevars + self._drain_instances(lowered, lowered_classes, fences) lowered.extend(self.nested_fns) lowered_classes.extend(self._synthetic_classes({item["name"] for item in lowered_classes})) self.function = None @@ -450,6 +479,100 @@ def _ir_name(self, name: str) -> str: return f"{self.module_name}.{name}" return name + def _drain_instances( + self, + lowered: list[dict[str, Any]], + lowered_classes: list[dict[str, Any]], + fences: list[Fence], + ) -> None: + """Lower every queued TypeVar instantiation to a concrete copy. + + Call sites discover specializations while ordinary bodies lower; + each queued entry is lowered here with `self.tv_subst` holding its + TypeVar mapping so annotations inside the copy resolve concretely. + New specializations found inside a copy just extend the queues. + """ + while self.inst_queue or self.class_queue: + while self.class_queue: + node, emit_name, mapping = self.class_queue.pop(0) + self.function = emit_name + self.caught = [] + self.tv_subst = mapping + try: + record = self._lower_class(node, emit_name) + if record is not None and not self.caught: + lowered_classes.append(record) + fences.extend(self.caught) + except Fence as exc: + fences.append(exc) + fences.extend(self.caught) + finally: + self.tv_subst = {} + while self.inst_queue: + fn_name, emit_name, mapping = self.inst_queue.pop(0) + fn = self.generic_fns[fn_name] + self.function = emit_name + self.temp_n = 0 + self.prelude = [] + self.caught = [] + self.fn_globals = self._globals_for(fn.body) + self.fn_nonlocals = set() + self.captures = {} + params, ret = self.sigs[fn_name] + params = [{**param, "type": _subst_type(param["type"], mapping)} for param in params] + ret = _subst_type(ret, mapping) + # A param whose TypeVar resolved to none takes no runtime + # slot (void is not a value); it drops from the signature + # and binds as the `None` constant for the body. + dropped = {param["name"] for param in params if param["type"]["kind"] == "none"} + params = [param for param in params if param["name"] not in dropped] + self.locals = {param["name"]: param["type"] for param in params} + saved_consts = {name: self.constants.get(name) for name in dropped} + for name in dropped: + self.constants[name] = {"kind": "const", "value": None, "type": {"kind": "none"}} + self.frozen_names = set() + self.opt_unwrap = {} + self.boxed_names = set() + self.fn_body = list(fn.body) + self.tv_subst = mapping + self.inst_sig = (params, ret) + saved_bindings = dict(self.bindings) + saved_aliases = dict(self.type_aliases) + saved_declared = set(self.declared) + saved_typevars = dict(self.typevars) + try: + body = self._body(list(fn.body)) + if self.caught: + fences.extend(self.caught) + continue + if ret["kind"] != "none" and not _always_returns(body): + self.fail(fn, "PS1004", f"{fn.name} can fall off the end and return None") + record = { + "name": self._ir_name(emit_name), + "params": params, + "returnType": ret, + "locals": [{"name": name, "type": typ} for name, typ in self.locals.items()], + "body": body, + "span": span_of(fn, self.filename), + } + if self.boxed_names: + record["boxed"] = sorted(self.boxed_names) + lowered.append(record) + except Fence as exc: + fences.append(exc) + finally: + self.bindings = saved_bindings + self.type_aliases = saved_aliases + self.declared = saved_declared + self.typevars = saved_typevars + self.tv_subst = {} + self.inst_sig = None + for name, prev in saved_consts.items(): + if prev is None: + self.constants.pop(name, None) + else: + self.constants[name] = prev + def _bind_one_export(self, module: str, name: str, local: str, stmt: ast.AST, fences: list[Fence]) -> None: package = self.package or {} item = package.get(module, {}).get(name) diff --git a/src/pyscriptc/lower/_calls.py b/src/pyscriptc/lower/_calls.py index f8e1e6c3..8ff414f3 100644 --- a/src/pyscriptc/lower/_calls.py +++ b/src/pyscriptc/lower/_calls.py @@ -21,7 +21,8 @@ from pyscriptc.builtins import builtin_spec, method_spec from pyscriptc.errors import span_of -from pyscriptc.ir import types_equal +from pyscriptc.ir import type_key, types_equal +from pyscriptc.lower._classes import _type_slug from pyscriptc.lower._core import ( _assign, _attr, @@ -31,9 +32,12 @@ _const_int, _const_none, _dotted, + _has_typevar, _len_call, _name, _show, + _sig_typevars, + _subst_type, ) from pyscriptc.lower._stdlib_shared import _mod_call, _synth_module from pyscriptc.restlower import _mk_iter, builtin_call @@ -64,6 +68,8 @@ def _call_name(self, node: ast.Call, span: dict[str, Any], expect: dict[str, Any return found if name in self.type_aliases: self.fail(node, "PS2002", f"{name} is a typing name and is not a value") + if name in self.typevars: + self.fail(node, "PS2002", f"{name} is a TypeVar and is not a value") if name in {"int", "float", "len", "str", "id"}: if name == "float": special = _float_special_arg(node) @@ -134,6 +140,8 @@ def _call_name(self, node: ast.Call, span: dict[str, Any], expect: dict[str, Any "span": span, } if name in self.sigs: + if name in self.generic_fns: + return self._generic_call(node, span, name) params, ret = self.sigs[name] args = self._fill_args(node, params, name) return { @@ -146,6 +154,176 @@ def _call_name(self, node: ast.Call, span: dict[str, Any], expect: dict[str, Any self.fail(node, "PS2002", f"call to {name} is not lowered") raise AssertionError("unreachable") + def _generic_call(self, node: ast.Call, span: dict[str, Any], name: str) -> dict[str, Any]: + """A call to a generic function (`def ident(x: T) -> T`): unify the + argument types with the signature's TypeVars, emit a specialized + `ident.int`-style copy, and call it.""" + params, ret = self.sigs[name] + mapping: dict[str, dict[str, Any]] = {} + args = self._fill_generic_args(node, params, mapping, name) + emit = self._fn_instance(node, name, params, ret, mapping) + # Params whose TypeVar resolved to none drop from the emitted + # signature; their args drop with them. + args = [ + arg + for arg, param in zip(args, params, strict=True) + if _subst_type(param["type"], mapping)["kind"] != "none" + ] + return { + "kind": "call", + "callee": {"kind": "function", "name": self._ir_name(emit)}, + "args": args, + "type": _subst_type(ret, mapping), + "span": span, + } + + def _fn_instance( + self, + node: ast.Call, + name: str, + params: list[dict[str, Any]], + ret: dict[str, Any], + mapping: dict[str, dict[str, Any]], + ) -> str: + """Bind every signature TypeVar (from the args, else its bound) and + queue the specialized body; returns the emitted function name.""" + tvars = _sig_typevars([*(param["type"] for param in params), ret]) + for tv in tvars: + if tv not in mapping: + bound = self.typevars.get(tv) + if bound is None: + self.fail(node, "PS1003", f"cannot infer {tv} for {name}") + mapping[tv] = bound + key = (name, tuple((tv, type_key(mapping[tv])) for tv in tvars)) + emit = self.inst_fns.get(key) + if emit is None: + emit = f"{name}.{'_'.join(_type_slug(mapping[tv]) for tv in tvars)}" + self.inst_fns[key] = emit + self.inst_queue.append((name, emit, dict(mapping))) + return emit + + def _generic_arg( + self, + arg_node: ast.expr, + param: dict[str, Any], + mapping: dict[str, dict[str, Any]], + node: ast.Call, + ) -> dict[str, Any]: + ptype = param["type"] + if not _has_typevar(ptype): + return self._expr(arg_node, ptype) + value = self._expr(arg_node) + self._unify(value["type"], ptype, mapping, node, arg_node) + return value + + def _unify( + self, + got: dict[str, Any], + want: dict[str, Any], + mapping: dict[str, dict[str, Any]], + node: ast.Call, + arg_node: ast.expr, + ) -> None: + """Bind the TypeVars in `want` against the concrete `got`, or check the + concrete parts match when nothing remains to bind.""" + if want["kind"] == "typevar": + tv = want["name"] + bound = self.typevars.get(tv) + existing = mapping.get(tv) + if existing is not None: + if not types_equal(existing, got): + self.fail(arg_node, "PS1003", f"{tv} is {_show(existing)}, not {_show(got)}") + return + if bound is not None: + self._expect(arg_node, got, bound, tv) + mapping[tv] = bound + else: + mapping[tv] = got + return + if want["kind"] == "list" and got["kind"] == "list": + self._unify(got["elem"], want["elem"], mapping, node, arg_node) + return + if want["kind"] == "dict" and got["kind"] == "dict": + self._unify(got["key"], want["key"], mapping, node, arg_node) + self._unify(got["value"], want["value"], mapping, node, arg_node) + return + if want["kind"] == "func" and got["kind"] == "func": + for got_param, want_param in zip(got["params"], want["params"], strict=False): + self._unify(got_param, want_param, mapping, node, arg_node) + self._unify(got["return"], want["return"], mapping, node, arg_node) + return + if ( + want["kind"] == "class" + and got["kind"] == "class" + and got["name"] == want["name"] + and isinstance(want.get("elem"), dict) + and isinstance(got.get("elem"), dict) + ): + self._unify(got["elem"], want["elem"], mapping, node, arg_node) + return + self._expect(arg_node, got, want, node.func.id if isinstance(node.func, ast.Name) else "call") + + def _fill_generic_args( + self, + node: ast.Call, + params: list[dict[str, Any]], + mapping: dict[str, dict[str, Any]], + what: str, + ) -> list[dict[str, Any]]: + """`_fill_args` for a signature with TypeVar placeholders: each + argument lowers once; placeholder parameters unify their declared + TypeVars against the argument type instead of expecting it.""" + if any(kw.arg is None for kw in node.keywords): + self.fail(node, "PS2002", "**kwargs are not lowered") + if any(isinstance(arg, ast.Starred) for arg in node.args): + self.fail(node, "PS2002", "starred calls are not lowered") + var_index = next((index for index, param in enumerate(params) if param.get("vararg")), None) + ordinary = params if var_index is None else params[:var_index] + if var_index is None and len(node.args) > len(params): + self.fail(node, "PS1003", f"{what} expects {len(params)} argument(s)") + if var_index is not None and len(node.args) < len(ordinary): + self.fail(node, "PS1003", f"{what} expects {len(ordinary)} argument(s)") + slots: list[dict[str, Any] | None] = [None] * len(params) + names = [param["name"] for param in params] + limit = len(node.args) if var_index is None else min(len(node.args), len(ordinary)) + for index in range(limit): + slots[index] = self._generic_arg(node.args[index], params[index], mapping, node) + seen = {param["name"] for param, slot in zip(params, slots, strict=True) if slot is not None} + for kw in node.keywords: + if kw.arg in seen: + self.fail(node, "PS1003", f"{what} gets {kw.arg} twice") + if kw.arg not in names: + self.fail(node, "PS1003", f"{what} does not take {kw.arg}") + index = names.index(kw.arg) + seen.add(kw.arg) + slots[index] = self._generic_arg(kw.value, params[index], mapping, node) + args: list[dict[str, Any]] = [] + for index, param in enumerate(params): + if param.get("vararg"): + continue + got = slots[index] + if got is None: + if "default" not in param: + self.fail(node, "PS1003", f"{what} misses {param['name']}") + got = param["default"] + if param.get("optdefault"): + got = self._opt_box(got, param["type"], span_of(node, self.filename)) + want = _subst_type(param["type"], mapping) + if not _has_typevar(want) and not types_equal(got["type"], want): + self.fail(node, "PS1003", f"argument {param['name']} of {what} has the wrong type") + args.append(got) + if var_index is not None: + varparam = params[var_index] + elem = varparam["type"]["elem"] + elems = [] + for arg_node in node.args[len(ordinary) :]: + value = self._expr(arg_node) + self._unify(value["type"], elem, mapping, node, arg_node) + elems.append(value) + var_type = _subst_type(varparam["type"], mapping) + args.append({"kind": "list", "elems": elems, "type": var_type, "span": span_of(node, self.filename)}) + return args + def _super_call(self, node: ast.Call, span: dict[str, Any]) -> dict[str, Any]: """`super().method(args)`: resolution starts one MRO step past the class the enclosing method text lives in (CPython's __class__ cell). @@ -168,9 +346,7 @@ class the enclosing method text lives in (CPython's __class__ cell). self.fail(node, "PS2002", "super is not lowered") method = node.func.attr ancestors = mro[mro.index(owner) + 1 :] - ancestor = next( - (name for name in ancestors if method in self.classes.get(name, {}).get("methods", {})), None - ) + ancestor = next((name for name in ancestors if method in self.classes.get(name, {}).get("methods", {})), None) if ancestor is None: self.fail(node, "PS2001", f"{self_t}.{method} is not a method") sig = self.classes[ancestor]["methods"][method] diff --git a/src/pyscriptc/lower/_classes.py b/src/pyscriptc/lower/_classes.py index 731b9838..ce782774 100644 --- a/src/pyscriptc/lower/_classes.py +++ b/src/pyscriptc/lower/_classes.py @@ -21,9 +21,12 @@ _SET_FORMS, Fence, _assign, + _has_typevar, _name, _plain_const_kind, _show, + _sig_typevars, + _subst_type, ) from pyscriptc.lower._stmt import _always_returns, _is_docstring from pyscriptc.restlower import alias_name, path_attr @@ -105,9 +108,17 @@ def _collect_class(self, node: ast.ClassDef) -> None: # class C(object) and class C(object,) are the same class as class C. # object.__init__ is not emitted. bases: list[dict[str, str]] = [] + type_params: list[str] = [] for base_node in node.bases: if self._is_builtin_object(base_node): continue + generic_params = self._generic_base(base_node) + if generic_params is not None: + # `class C(Generic[T])` erases: the parameter list records + # nothing at runtime; the declared TypeVars parameterize + # the class's annotations for call-site instantiation. + type_params = generic_params + continue resolved = self._resolve_base(base_node) if resolved["kind"] != "class" and len(node.bases) > 1: self.fail(node, "PS2002", "multiple inheritance needs lowered class bases") @@ -288,6 +299,8 @@ def _collect_class(self, node: ast.ClassDef) -> None: info["exception"] = True info["exc_code"] = self.exc_n self.exc_n += 1 + if type_params: + info["typevars"] = type_params self.classes[node.name] = info def _mro_chain(self, node: ast.ClassDef, bases: list[dict[str, str]]) -> tuple[list[str], set[str]]: @@ -491,8 +504,9 @@ def _enum_assign(self, stmt: ast.stmt) -> tuple[str, ast.expr]: self.fail(stmt, "PS2002", "an enum body is integer constants") raise AssertionError("unreachable") - def _lower_class(self, node: ast.ClassDef) -> dict[str, Any] | None: - info = self.classes[node.name] + def _lower_class(self, node: ast.ClassDef, emit_name: str | None = None) -> dict[str, Any] | None: + own = emit_name or node.name + info = self.classes[own] if info.get("enum"): return None own_names = {field["name"] for field in info.get("own_fields", info["fields"])} @@ -500,17 +514,17 @@ def _lower_class(self, node: ast.ClassDef) -> dict[str, Any] | None: for stmt in node.body: if not isinstance(stmt, ast.FunctionDef): continue - if info["method_owner"].get(stmt.name) != node.name: + if info["method_owner"].get(stmt.name) != own: continue - emitted = self._emit_class_method(node.name, stmt, info, own_names) + emitted = self._emit_class_method(own, stmt, info, own_names) if emitted is not None: methods.append(emitted) for mname in info["methods"]: # Monomorphize inherited instance methods whose owner sits off the # primary chain: the owner's fields are not a layout prefix of this # class, so its body is re-lowered with self typed as this class. - owner = info["method_owner"].get(mname, node.name) - if owner == node.name or owner in info.get("chain", {node.name}): + owner = info["method_owner"].get(mname, own) + if owner == own or owner in info.get("chain", {own}): continue if info["method_kind"].get(mname, "method") not in {"method", "property"}: continue @@ -524,7 +538,7 @@ def _lower_class(self, node: ast.ClassDef) -> dict[str, Any] | None: ) if ostmt is None: continue - emitted = self._emit_class_method(node.name, ostmt, info, own_names) + emitted = self._emit_class_method(own, ostmt, info, own_names) if emitted is not None: methods.append(emitted) if self.caught: @@ -533,7 +547,7 @@ def _lower_class(self, node: ast.ClassDef) -> dict[str, Any] | None: if generated is not None: methods.append(generated) return { - "name": node.name, + "name": own, "fields": info["fields"], "methods": methods, "span": span_of(node, self.filename), @@ -566,12 +580,14 @@ def _emit_class_method( saved_bindings = dict(self.bindings) saved_aliases = dict(self.type_aliases) saved_declared = set(self.declared) + saved_typevars = dict(self.typevars) try: body = self._body(list(stmt.body)) finally: self.bindings = saved_bindings self.type_aliases = saved_aliases self.declared = saved_declared + self.typevars = saved_typevars if self.caught: return None if stmt.name == "__init__": @@ -716,6 +732,8 @@ def _construct(self, node: ast.Call, span: dict[str, Any], name: str) -> dict[st if info.get("namedtuple"): return self._construct_namedtuple(node, span, name, info) init = info["methods"].get("__init__") + if info.get("typevars"): + return self._construct_generic(node, span, name, init) if init is None: if node.args or node.keywords: self.fail(node, "PS2003", f"{name} takes no arguments") @@ -736,6 +754,41 @@ def _construct(self, node: ast.Call, span: dict[str, Any], name: str) -> dict[st "span": span, } + def _construct_generic( + self, node: ast.Call, span: dict[str, Any], name: str, init: tuple[Any, Any] | None + ) -> dict[str, Any]: + """`Box(3)` on a `class Box(Generic[T])`: unify the arguments with the + __init__ parameters to bind its TypeVars, then emit the call against + the specialized `Box_int`-style class record.""" + if init is None: + self.fail(node, "PS1003", f"cannot infer type arguments for {name}") + params = [param for param in init[0] if param["name"] != "self"] + mapping: dict[str, dict[str, Any]] = {} + args = self._fill_generic_args(node, params, mapping, name) + tvars = self.classes[name].get("typevars") or [] + for param in params: + for tv in _sig_typevars([param["type"]]): + if tv not in mapping: + bound = self.typevars.get(tv) + if bound is None: + self.fail(node, "PS1003", f"cannot infer {tv} for {name}") + mapping[tv] = bound + missing = [tv for tv in tvars if tv not in mapping] + if missing: + self.fail(node, "PS1003", f"cannot infer {missing[0]} for {name}") + inst = self._instantiate_class(name, [mapping[tv] for tv in tvars], node) + for arg, param in zip(args, params, strict=True): + want = _subst_type(param["type"], mapping) + if not _has_typevar(want): + self._expect(node, arg["type"], want, name) + return { + "kind": "call", + "callee": {"kind": "class", "name": inst}, + "args": args, + "type": {"kind": "class", "name": inst}, + "span": span, + } + def _namedtuple_call(self, node: ast.expr) -> ast.Call | None: """The `collections.namedtuple(...)` call, spelled either binding or `collections.namedtuple`.""" if not isinstance(node, ast.Call): @@ -857,6 +910,13 @@ def _reject_banned_type(self, node: ast.AST, typ: dict[str, Any]) -> None: self.fail(node, "PS2002", "dict values of bool are not lowered") def _annotation(self, node: ast.AST) -> dict[str, Any]: + if isinstance(node, ast.Name) and node.id in self.tv_subst: + return self.tv_subst[node.id] + if isinstance(node, ast.Name) and node.id in self.typevars: + bound = self.typevars[node.id] + if bound is not None: + return bound + return {"kind": "typevar", "name": node.id} if isinstance(node, ast.BinOp) and isinstance(node.op, ast.BitOr): opt = self._optional_pipe(node) if opt is not None: @@ -870,9 +930,7 @@ def _annotation(self, node: ast.AST) -> dict[str, Any]: if isinstance(node, ast.Constant) and node.value is None: return {"kind": "none"} if isinstance(node, ast.Name) and ( - node.id in EXCEPTIONS - or node.id == "BaseException" - or self.classes.get(node.id, {}).get("exception") + node.id in EXCEPTIONS or node.id == "BaseException" or self.classes.get(node.id, {}).get("exception") ): return {"kind": "exc"} if isinstance(node, ast.Name) and (node.id in self.declared or node.id in self.known_classes): @@ -904,6 +962,9 @@ def _annotation(self, node: ast.AST) -> dict[str, Any]: named = self._annotation_class(node) if named is not None: return {"kind": "class", "name": named} + inst = self._generic_alias_annotation(node) + if inst is not None: + return inst self._fail_annotation(node) def _fail_annotation(self, node: ast.AST) -> NoReturn: @@ -920,6 +981,132 @@ def _fail_annotation(self, node: ast.AST) -> NoReturn: self.fail(node, "PS1106", f"the annotation {text} names a type that is not lowered") self.fail(node, "PS1001", "this annotation is not in the lowered subset") + def _typevar_call(self, node: ast.expr) -> ast.Call | None: + """The `TypeVar('T', ...)` call, spelled through a `from typing import + TypeVar` local or an erased `typing.TypeVar` attribute.""" + if not isinstance(node, ast.Call): + return None + func = node.func + if isinstance(func, ast.Name): + if self.type_aliases.get(func.id) == "TypeVar": + return node + return None + if isinstance(func, ast.Attribute): + resolved = self._resolve_module(func) + if resolved is not None and resolved[3] == "erased" and resolved[2] == "TypeVar": + return node + return None + + def _declare_typevar(self, target: str, call: ast.Call, stmt: ast.AST) -> None: + """`T = TypeVar('T')` registers `T` as a TypeVar and lowers as a no-op. + + `bound=` records a bound type its annotations erase to directly; + variance flags are ignored; an unbound TypeVar annotates as a + `{"kind": "typevar"}` placeholder that only call-site + monomorphization resolves.""" + if target in self.bindings or target in self.declared or target in self.constants or target in self.typevars: + self.fail(stmt, "PS2002", f"{target} is already bound") + if not call.args or _plain_const_kind(call.args[0]) != "str": + self.fail(call, "PS2003", "TypeVar needs a str name") + bound: dict[str, Any] | None = None + for kw in call.keywords: + if kw.arg == "bound": + typ = self._annotation(kw.value) + bound = typ if typ["kind"] != "typevar" else None + elif kw.arg in {"covariant", "contravariant"}: + continue + else: + self.fail(kw.value, "PS2003", f"TypeVar keyword {kw.arg} is not lowered") + self.typevars[target] = bound + self.declared.add(target) + + def _is_generic_name(self, node: ast.expr) -> bool: + if isinstance(node, ast.Name): + return self.type_aliases.get(node.id) == "Generic" + if isinstance(node, ast.Attribute): + resolved = self._resolve_module(node) + return resolved is not None and resolved[3] == "erased" and resolved[2] == "Generic" + return False + + def _generic_base(self, node: ast.expr) -> list[str] | None: + """A `Generic[T, ...]` (or bare `Generic`) base: the declared TypeVar + names it parameterizes over, or None when this is not a Generic base.""" + if isinstance(node, ast.Subscript): + if not self._is_generic_name(node.value): + return None + elts = node.slice.elts if isinstance(node.slice, ast.Tuple) else [node.slice] + return [elt.id for elt in elts if isinstance(elt, ast.Name) and elt.id in self.typevars] + if self._is_generic_name(node): + return [] + return None + + def _generic_alias_annotation(self, node: ast.AST) -> dict[str, Any] | None: + """`Box[int]` on a user `class Box(Generic[T])`: instantiate the class + at the argument types; the annotation resolves to `Box_int`.""" + if not isinstance(node, ast.Subscript) or not isinstance(node.value, ast.Name): + return None + info = self.classes.get(node.value.id) + if info is None or not info.get("typevars"): + return None + elts = node.slice.elts if isinstance(node.slice, ast.Tuple) else [node.slice] + args = [self._annotation(elt) for elt in elts] + name = self._instantiate_class(node.value.id, args, node) + return {"kind": "class", "name": name} + + def _instantiate_class(self, base: str, arg_types: list[dict[str, Any]], node: ast.AST) -> str: + """Register (or reuse) `base` specialized at `arg_types` and return the + emitted class name; the specialized record is queued to lower.""" + info = self.classes[base] + tvars = info.get("typevars") or [] + if len(arg_types) != len(tvars): + self.fail(node, "PS1003", f"{base} takes {len(tvars)} type argument(s)") + mapping: dict[str, dict[str, Any]] = {} + for tv, typ in zip(tvars, arg_types, strict=True): + if typ["kind"] == "none": + self.fail(node, "PS1003", f"cannot instantiate {base} with none") + bound = self.typevars.get(tv) + if bound is not None and not types_equal(typ, bound): + self.fail(node, "PS1003", f"{_show(typ)} is outside the bound {_show(bound)} of {tv}") + mapping[tv] = typ + key = (base, tuple(type_key(typ) for typ in arg_types)) + name = self.class_insts.get(key) + if name is not None: + return name + name = f"{base}_{'_'.join(_type_slug(typ) for typ in arg_types)}" + if name in self.classes or name in self.declared: + self.fail(node, "PS2002", f"{name} is already bound") + inst = copy.deepcopy(info) + inst["fields"] = [{**field, "type": _subst_type(field["type"], mapping)} for field in inst["fields"]] + inst["own_fields"] = [ + {**field, "type": _subst_type(field["type"], mapping)} for field in inst.get("own_fields", []) + ] + for field in inst["fields"] + inst["own_fields"]: + default = field.get("default") + if default is not None: + default["type"] = _subst_type(default["type"], mapping) + inst["methods"] = { + mname: ( + [{**param, "type": _subst_type(param["type"], mapping)} for param in params], + _subst_type(ret, mapping), + ) + for mname, (params, ret) in inst["methods"].items() + } + inst["method_owner"] = { + mname: (name if owner == base else owner) for mname, owner in inst["method_owner"].items() + } + inst.pop("typevars", None) + inst["instance_of"] = base + inst["mro"] = [name, base] + inst["chain"] = {name} + self.classes[name] = inst + self.declared.add(name) + self.known_classes.add(name) + self.class_insts[key] = name + base_node = info.get("node") + if base_node is not None: + self.class_queue.append((base_node, name, mapping)) + return name + def _class_binary( self, node: ast.BinOp, left: dict[str, Any], right: dict[str, Any], span: dict[str, Any] ) -> dict[str, Any]: diff --git a/src/pyscriptc/lower/_closures.py b/src/pyscriptc/lower/_closures.py index a9e198a5..e0727962 100644 --- a/src/pyscriptc/lower/_closures.py +++ b/src/pyscriptc/lower/_closures.py @@ -133,7 +133,7 @@ def _partial_callable_inner(self, node: ast.Call, span: dict[str, Any]) -> dict[ def _callable_name_type(self, name: str) -> dict[str, Any] | None: """The func type a bare name carries, from any scope, else None.""" - if name in self.sigs: + if name in self.sigs and name not in self.generic_fns: params, ret = self.sigs[name] return {"kind": "func", "params": [param["type"] for param in params], "return": ret} ftype = self.locals.get(name, self.captures.get(name)) diff --git a/src/pyscriptc/lower/_core.py b/src/pyscriptc/lower/_core.py index 7780f5c6..0f2ca727 100644 --- a/src/pyscriptc/lower/_core.py +++ b/src/pyscriptc/lower/_core.py @@ -334,3 +334,60 @@ def _declared_globals(body: list[ast.stmt]) -> set[str]: if isinstance(node, ast.Global): names.update(node.names) return names + + +def _has_typevar(typ: dict[str, Any]) -> bool: + """True when a type carries a `{"kind": "typevar"}` placeholder.""" + if typ["kind"] == "typevar": + return True + for key in ("elem", "key", "value", "return"): + inner = typ.get(key) + if isinstance(inner, dict) and _has_typevar(inner): + return True + return any(isinstance(param, dict) and _has_typevar(param) for param in typ.get("params", [])) + + +def _sig_typevars(types: list[dict[str, Any]]) -> list[str]: + """TypeVar names appearing in `types`, first-appearance order.""" + found: list[str] = [] + + def walk(typ: dict[str, Any]) -> None: + if typ["kind"] == "typevar": + if typ["name"] not in found: + found.append(typ["name"]) + return + for key in ("elem", "key", "value", "return"): + inner = typ.get(key) + if isinstance(inner, dict): + walk(inner) + for param in typ.get("params", []): + if isinstance(param, dict): + walk(param) + + for typ in types: + walk(typ) + return found + + +def _subst_type(typ: dict[str, Any], mapping: dict[str, dict[str, Any]]) -> dict[str, Any]: + """Replace `{"kind": "typevar"}` placeholders by the mapped concrete type.""" + kind = typ["kind"] + if kind == "typevar": + return mapping.get(typ["name"], typ) + if kind == "list": + return {**typ, "elem": _subst_type(typ["elem"], mapping)} + if kind == "dict": + return { + **typ, + "key": _subst_type(typ["key"], mapping), + "value": _subst_type(typ["value"], mapping), + } + if kind == "func": + return { + **typ, + "params": [_subst_type(param, mapping) for param in typ["params"]], + "return": _subst_type(typ["return"], mapping), + } + if "elem" in typ and isinstance(typ["elem"], dict): + return {**typ, "elem": _subst_type(typ["elem"], mapping)} + return typ diff --git a/src/pyscriptc/lower/_expr.py b/src/pyscriptc/lower/_expr.py index b3fbf30a..18c12647 100644 --- a/src/pyscriptc/lower/_expr.py +++ b/src/pyscriptc/lower/_expr.py @@ -202,6 +202,8 @@ def _name(self, node: ast.Name, span: dict[str, Any]) -> dict[str, Any]: return {"kind": "const", "value": self.module_name or "__main__", "type": {"kind": "str"}, "span": span} if node.id in self.type_aliases: self.fail(node, "PS2002", f"{node.id} is a typing name and is not a value") + if node.id in self.typevars: + self.fail(node, "PS2002", f"{node.id} is a TypeVar and is not a value") binding = self.bindings.get(node.id) if binding is not None: if binding.queued: @@ -224,6 +226,8 @@ def _name(self, node: ast.Name, span: dict[str, Any]) -> dict[str, Any]: if node.id in self.classes: self.fail(node, "PS2002", f"class {node.id} is not a value") if node.id in self.sigs: + if node.id in self.generic_fns: + self.fail(node, "PS2002", f"{node.id} is generic and is not a value") # A function referenced as a value: the closure wraps it with an # empty environment (the emitter adds a lazy env-first shim). params, ret = self.sigs[node.id] diff --git a/src/pyscriptc/lower/_stdlib.py b/src/pyscriptc/lower/_stdlib.py index dfcf2b0c..33f325b4 100644 --- a/src/pyscriptc/lower/_stdlib.py +++ b/src/pyscriptc/lower/_stdlib.py @@ -531,6 +531,10 @@ def _bind_module_assign(self, stmt: ast.stmt) -> None: if nt_call is not None: self._namedtuple_decl(target, nt_call, stmt) return + tv_call = self._typevar_call(stmt.value) + if tv_call is not None: + self._declare_typevar(target, tv_call, stmt) + return if ( not self._deferring and self._script_line is None diff --git a/src/pyscriptc/lower/_stmt.py b/src/pyscriptc/lower/_stmt.py index 5e1f4767..ab38d725 100644 --- a/src/pyscriptc/lower/_stmt.py +++ b/src/pyscriptc/lower/_stmt.py @@ -34,6 +34,7 @@ _const_int, _const_none, _const_str, + _has_typevar, _is_none_expr, _item, _len_call, @@ -165,6 +166,8 @@ def _stmt_core(self, stmt: ast.stmt) -> list[dict[str, Any]]: if stmt.target.id in self.fn_nonlocals: self.fail(stmt, "PS2002", f"nonlocal {stmt.target.id} cannot be annotated") typ = self._annotation(stmt.annotation) + if _has_typevar(typ): + self.fail(stmt, "PS1001", f"{stmt.target.id} needs a concrete type") if stmt.value is None: self.locals[stmt.target.id] = typ return [_assign(stmt.target.id, _zero(typ, span), span)] @@ -187,6 +190,13 @@ def _stmt_core(self, stmt: ast.stmt) -> list[dict[str, Any]]: ): self._namedtuple_decl(stmt.targets[0].id, nt_call, stmt) return [{"kind": "pass", "span": span}] + if ( + len(stmt.targets) == 1 + and isinstance(stmt.targets[0], ast.Name) + and (tv_call := self._typevar_call(stmt.value)) is not None + ): + self._declare_typevar(stmt.targets[0].id, tv_call, stmt) + return [{"kind": "pass", "span": span}] if len(stmt.targets) != 1 or not isinstance(stmt.targets[0], ast.Name): return self._spread_assign(stmt, span) target = stmt.targets[0].id @@ -333,7 +343,12 @@ def _stmt_core(self, stmt: ast.stmt) -> list[dict[str, Any]]: if isinstance(stmt, ast.For): return self._for(stmt, span) if isinstance(stmt, ast.Return): - _params, ret = self.sigs[self.function or ""] if self.function in self.sigs else self._current_method() + if self.inst_sig is not None: + _params, ret = self.inst_sig + elif self.function in self.sigs: + _params, ret = self.sigs[self.function or ""] + else: + _params, ret = self._current_method() if stmt.value is None or (isinstance(stmt.value, ast.Constant) and stmt.value.value is None): if ret.get("optional"): return [{"kind": "return", "value": self._opt_box(None, ret, span), "span": span}] From 2b01f9628c4533fdc176b075a9fdcfa101513585 Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:27:24 +0000 Subject: [PATCH 2/3] test(lower): tier-2 TypeVar/Generic erasure coverage --- tests/test_t2_typevar.py | 417 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 tests/test_t2_typevar.py diff --git a/tests/test_t2_typevar.py b/tests/test_t2_typevar.py new file mode 100644 index 00000000..e451ee45 --- /dev/null +++ b/tests/test_t2_typevar.py @@ -0,0 +1,417 @@ +"""T2-1: TypeVar/Generic annotation erasure (tier-2 typed lowering). + +`T = TypeVar('T')` declares a compile-time name, not a value. Annotations +on an unbound TypeVar are placeholders resolved per call site: each call +monomorphizes a concrete copy (`ident(1)` calls `ident.int`; `Box[int]` +instantiates `Box_int`). Bound TypeVars erase to their bound outright. +Runtime machinery stays fenced: a TypeVar is not a value, a generic +function is not a value, and a `none` field cannot instantiate. +""" + +from __future__ import annotations + +from pathlib import Path + +from tests.test_gates import compile_run, oracle, run_cli, write_case + + +def test_t2_typevar_decl_is_a_noop(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_decl.py", + "\n".join( + [ + "from typing import TypeVar", + "", + "T = TypeVar('T')", + "", + "def main() -> int:", + " return 1", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "main") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "main") + + +def test_t2_ident_on_int(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_ident_int.py", + "\n".join( + [ + "from typing import TypeVar", + "", + "T = TypeVar('T')", + "", + "def ident(x: T) -> T:", + " return x", + "", + "def main() -> int:", + " return ident(7)", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "main") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "main") + + +def test_t2_ident_on_str(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_ident_str.py", + "\n".join( + [ + "from typing import TypeVar", + "", + "T = TypeVar('T')", + "", + "def ident(x: T) -> T:", + " return x", + "", + "def main() -> str:", + " return ident('seven')", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "main") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "main") + + +def test_t2_ident_on_none(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_ident_none.py", + "\n".join( + [ + "from typing import TypeVar", + "", + "T = TypeVar('T')", + "", + "def ident(x: T) -> T:", + " return x", + "", + "def main() -> str:", + " return str(ident(None))", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "main") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "main") + + +def test_t2_bound_typevar_erases_to_bound(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_bound.py", + "\n".join( + [ + "from typing import TypeVar", + "", + "N = TypeVar('N', bound=int)", + "", + "def dbl(x: N) -> N:", + " return x", + "", + "def main() -> int:", + " return dbl(21)", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "main") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "main") + + +def test_t2_variance_flags_ignored(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_variance.py", + "\n".join( + [ + "from typing import TypeVar", + "", + "V = TypeVar('V', covariant=True)", + "W = TypeVar('W', contravariant=True)", + "", + "def take(x: W) -> int:", + " return 0", + "", + "def main() -> int:", + " return take(1)", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "main") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "main") + + +def test_t2_generic_class_alias_and_ctor(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_box.py", + "\n".join( + [ + "from typing import TypeVar, Generic", + "", + "T = TypeVar('T')", + "", + "class Box(Generic[T]):", + " item: T", + "", + " def __init__(self, item: T) -> None:", + " self.item = item", + "", + " def get(self) -> T:", + " return self.item", + "", + "def pick(b: Box[int]) -> int:", + " return b.get()", + "", + "def main() -> int:", + " b: Box[int] = Box(3)", + " b.item = 4", + " return pick(b) + b.item", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "main") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "main") + + +def test_t2_generic_class_two_instantiations(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_box_two.py", + "\n".join( + [ + "from typing import TypeVar, Generic", + "", + "T = TypeVar('T')", + "", + "class Box(Generic[T]):", + " item: T", + "", + " def __init__(self, item: T) -> None:", + " self.item = item", + "", + " def get(self) -> T:", + " return self.item", + "", + "def main() -> str:", + " n: Box[int] = Box(3)", + " s: Box[str] = Box('x')", + " return str(n.get()) + s.get()", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "main") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "main") + + +def test_t2_builtin_generic_alias_untouched(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_list.py", + "\n".join( + [ + "from typing import TypeVar", + "", + "T = TypeVar('T')", + "", + "def main() -> int:", + " xs: list[int] = [1, 2, 3]", + " return len(xs)", + "", + ] + ), + ) + got = compile_run(tmp_path, path, "main") + assert got.returncode == 0, got.stderr + assert got.stdout == oracle(path, "main") + + +def test_t2_typevar_as_value_fenced(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_tv_value.py", + "\n".join( + [ + "from typing import TypeVar", + "", + "T = TypeVar('T')", + "", + "def main() -> int:", + " return T(1)", + "", + ] + ), + ) + got = run_cli("compile", str(path), "--skip-check", "--emit", "llvm", "-o", str(tmp_path / "t2")) + assert got.returncode == 3 + assert "PS2002" in got.stderr + + +def test_t2_typevar_read_fenced(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_tv_read.py", + "\n".join( + [ + "from typing import TypeVar", + "", + "T = TypeVar('T')", + "", + "def main() -> int:", + " print(T)", + " return 0", + "", + ] + ), + ) + got = run_cli("compile", str(path), "--skip-check", "--emit", "llvm", "-o", str(tmp_path / "t2")) + assert got.returncode == 3 + assert "PS2002" in got.stderr + + +def test_t2_generic_fn_as_value_fenced(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_fn_value.py", + "\n".join( + [ + "from typing import TypeVar", + "", + "T = TypeVar('T')", + "", + "def ident(x: T) -> T:", + " return x", + "", + "def main() -> int:", + " print(ident)", + " return 0", + "", + ] + ), + ) + got = run_cli("compile", str(path), "--skip-check", "--emit", "llvm", "-o", str(tmp_path / "t2")) + assert got.returncode == 3 + assert "PS2002" in got.stderr + + +def test_t2_bare_typevar_local_fenced(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_bare_tv.py", + "\n".join( + [ + "from typing import TypeVar", + "", + "T = TypeVar('T')", + "", + "def main() -> int:", + " x: T = 1", + " return x", + "", + ] + ), + ) + got = run_cli("compile", str(path), "--skip-check", "--emit", "llvm", "-o", str(tmp_path / "t2")) + assert got.returncode == 3 + assert "PS1001" in got.stderr + + +def test_t2_conflicting_typevar_binding_fenced(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_conflict.py", + "\n".join( + [ + "from typing import TypeVar", + "", + "T = TypeVar('T')", + "", + "def same(x: T, y: T) -> T:", + " return x", + "", + "def main() -> int:", + " return same(1, 'two')", + "", + ] + ), + ) + got = run_cli("compile", str(path), "--skip-check", "--emit", "llvm", "-o", str(tmp_path / "t2")) + assert got.returncode == 3 + assert "PS1003" in got.stderr + + +def test_t2_none_instantiated_class_fenced(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_none_class.py", + "\n".join( + [ + "from typing import TypeVar, Generic", + "", + "T = TypeVar('T')", + "", + "class Box(Generic[T]):", + " item: T", + "", + " def __init__(self, item: T) -> None:", + " self.item = item", + "", + "def main() -> int:", + " b = Box(None)", + " return 0", + "", + ] + ), + ) + got = run_cli("compile", str(path), "--skip-check", "--emit", "llvm", "-o", str(tmp_path / "t2")) + assert got.returncode == 3 + assert "PS1003" in got.stderr + + +def test_t2_isinstance_on_generic_alias_fenced(tmp_path: Path) -> None: + path = write_case( + tmp_path, + "t2_isinstance.py", + "\n".join( + [ + "from typing import TypeVar, Generic", + "", + "T = TypeVar('T')", + "", + "class Box(Generic[T]):", + " item: T", + "", + " def __init__(self, item: T) -> None:", + " self.item = item", + "", + "def main() -> int:", + " b = Box(1)", + " print(isinstance(b, Box[int]))", + " return 0", + "", + ] + ), + ) + got = run_cli("compile", str(path), "--skip-check", "--emit", "llvm", "-o", str(tmp_path / "t2_isinstance")) + assert got.returncode == 3 + assert "PS" in got.stderr From 2ad797dd59e5ca26e3f1f4054814974460675e3a Mon Sep 17 00:00:00 2001 From: Quim T <26749475+espetro@users.noreply.github.com> Date: Sun, 18 Oct 2026 19:18:34 +0000 Subject: [PATCH 3/3] test(census): repin after T2-1 typevar erasure --- tests/test_census_self.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_census_self.py b/tests/test_census_self.py index ebb7ef29..8ffaf565 100644 --- a/tests/test_census_self.py +++ b/tests/test_census_self.py @@ -19,6 +19,6 @@ def test_self_census_ballpark() -> None: # telemetry.py added 36 fences, log.py 20, debug.py 6 union-type # fences (`X | None` parameters); R1–R5 batch un-fenced 10 self-host # call sites (list/dict/str methods). - assert census["fences"] == 1023 - assert 11_000 <= census["lines"] <= 21_000 - assert sum(p["fences"] for p in census["packages"]) == 1023 + assert census["fences"] == 1050 + assert 11_000 <= census["lines"] <= 21_500 + assert sum(p["fences"] for p in census["packages"]) == 1050