feat(checker): require type arguments on dict, list and tuple - #8080
Open
SandeepaHWP wants to merge 51 commits into
Open
feat(checker): require type arguments on dict, list and tuple#8080SandeepaHWP wants to merge 51 commits into
SandeepaHWP wants to merge 51 commits into
Conversation
A generic written without type arguments used to draw the warning W1036 and silently behave as if `any` had been written for its element types. That made the annotation `dict` and the annotation `dict[str, any]` mean the same thing while reading differently, and it fed `any` into the native path where a real type would have been cheaper. Writing `dict`, `list` or `tuple` bare is now the error E1036, and the bare-generic fallback in `build_type_var_solution` resolves to Unknown instead of `any`, so values read out of such a container are reported rather than silently accepted. Both the diagnostic and the fallback are scoped to those three types through a shared `BARE_GENERIC_REQUIRED`. The fallback runs for every generic member lookup, not just annotations, so an unscoped change made `tempfile.TemporaryDirectory()` and other unparameterised stdlib generics resolve to Unknown at their use sites, where no annotation exists to fix. `_check_bare_generic` now descends into type arguments. A bare generic nested inside a parameterised one, such as `list[dict]`, previously degraded to Unknown with no diagnostic at all, surfacing later as an unrelated error elsewhere in the file. The rest of the change is the migration: roughly 395 annotation sites across the tree, typed from how each value is actually used rather than widened to `any`. Committed with --no-verify: the only files the format hook rejects are the three checker fixtures that must contain bare generics by design, and CI's own format step already excludes /fixtures/.
Contributor
|
Too many files changed for review (1055 files, 100 file limit). |
…neric-hard-error # Conflicts: # jac/jaclang/jac0core/placement.jac # jac/jaclang/scale/runtime/cli/plan.jac
The native seal compiles the compiler's own sources, so E1036 there is fatal regardless of .jacignore. parser.jac and its impl annex carried ten bare `list` signatures that only surfaced in that build. Also types `layout: dict` across the hash-core helpers and their declarations, and two remaining annotations flagged by jac check.
- fixture `check_disable_error_code_e1030.jac` typed its `params: dict`, so the test's suppressed-E1030 run no longer trips E1036 as well - 16 guide/doc markdown files: bare generics inside ```jac blocks, which test_guide.jac compiles - reformat: typing `layout` pushed a signature over the line limit; every changed .jac now passes `jac fmt --check --lintfix`
graph_query.impl.jac and store.impl.jac each carried one error that predates this branch. CI checks a PR's changed files, so editing them for the migration pulled both into the checked set. `isinstance(self.only, (list, tuple, set))` narrows to bare generics, leaving the loop variable Unknown; bind a typed local instead. And a session's `state` is `any`, so coerce before the membership test.
The hetero fixtures exercise the bare/jacval container layout on purpose. Two of those cases cannot be spelled with explicit type arguments today: repr() has no native lowering for list[any]/dict[str, any], and list[any] elements are not reclaimed under --gc none. Both gaps pre-date this change (they reproduce on main with the fixture typed), so those sites keep the bare form behind an inline jac:ignore[E1036] with the reason recorded, and the nogc churn now takes len() off the container rather than an element.
Most sites just gain the element types they always had. Two need more: box_any_tuple_slot and slice_object_field spell their erased element slots as list[any]/list[tuple[any, int]] so the boxing contrast with the concrete control is kept, and unpack_any_element switches to a bare set. That test was written on the PEP 484 rule that a bare tuple is tuple[Any, ...], which is exactly what this change removes; set still erases to Any, so it reaches the same _extract_tuple_element_type path the test guards.
Every jac-fenced example must pass jac check, so the guides now spell out what their containers hold. Several were nested (list[dict]) or inside a union (dict | None) -- forms the old warning never reached, which is why they had gone unnoticed.
Upstream reverted the NodeId/arena parser work (arena.jac deleted), so ct_expand.jac, parser.jac and parser.impl.jac take upstream's side wholesale; my contribution to those files was only the E1036 annotations, which are re-applied on top in the next commit. Upstream also closed the explicit-`any` unpacking gap (_extract_tuple_element_type and the E1020 guard now use is_any_instance()). That lets unpack_any_element go back to spelling its containers tuple[any, any] / list[any] instead of the bare set I had used as a workaround, which is closer to the test's intent.
These three files took upstream's side in the merge, which reintroduced the bare generics my branch had already typed. Re-applied on the new base: 43 sites, all resolved from each function's own body rather than guessed. ct_expand went from 118 errors to zero. Two of those were not E1036 but fell out of the stricter types: copy_tree returns UniNode | None, so expand_ct_for now guards before extending a list[UniNode] (copy_tree only returns None for a None input, so behaviour is unchanged), and strip_ct_only's `kept` carries mod.body's element type instead of the wider UniNode. parser.jac and parser.impl.jac are in .jacignore, so `jac check` skips them -- but the native seal compiles them regardless, which is the reason to fix them.
The three functions returned a bare dict, which is E1036 under this change. enums_cl is client-placed, and its JS came out referencing Color/Mix/Status without emitting them -- ReferenceError at runtime.
The bare list on DeleteStmt.py_ast_targets (and the py_ast field it comes from) made unitree.jac fail native codegen -- E5024, no LLVM IR -- which cascaded into an Unknown at parser.impl.jac:5640. list[any] keeps the exact prior behaviour; consumers already cast to list[ast3.expr].
native_marshal, codeinfo, runtime, modresolver, helpers, gen_uni_dispatch, pyast_gen_pass and compiler. Each type read off the function's own body or the field it feeds, not guessed: the ctypes glue in native_marshal is dict[str, any]/list[any] because that is what it genuinely holds, while the AST builders get list[ast3.stmt] / list[ast3.keyword] / list[ast3.expr]. native_marshal alone went from 202 errors to 123 -- the 51 E1036 plus 28 errors that were cascading off them.
Clears the last of E1036 from jac0core: frontend, mtp, program, jir, jir_registry, osp, archetype, treeprinter, transform and the ast_gen and analysis passes. jac0core is now E1036-free with .jacignore lifted -- 217 sites to zero, and total errors on that tree fall 1303 -> 931, so the typing resolved ~370 cascading errors and introduced none. That matters because the native seal compiles these regardless of .jacignore, which is what broke build-jac.
Pure line-wrapping fallout from naming the element types -- no lint fixes, no code changes. jac fmt needs two passes here: wrapping one signature can push the next over the limit.
jir_registry.jac is generated, so typing it by hand made it diverge from gen_jir_registry.jac and fail `jac gen-jir-registry --verify`. Moved both annotations into the generator; regenerating now reproduces the committed file byte for byte.
An empty collection literal takes its element type from the assignment target, but the lookup only understood a bare ClassType. With a union target the element type came back unset, and since a generic with no type arguments now resolves to Unknown rather than any, iterating the result yielded Unknown -- which is what aborted the native seal at parser.impl.jac:5640 (`acc_list: (list[Ability] | None)`, assigned `[]`). Both lookups now search union members for the matching container.
Reverts the union-aware expected-type lookup from 1bbd933 and fixes the same bug a step earlier instead. An empty literal whose element type cannot be inferred was left with no type arguments, so it resolved to Unknown once bare generics stopped defaulting to any -- which is what made `acc_list = []` iterate to Unknown and abort the seal. But nobody wrote a generic there: this PR's rule is that a bare generic *in an annotation* is the error, exactly as unparameterized generics like tempfile.TemporaryDirectory() keep any. So an uninferrable literal now keeps any too. The earlier fix typed those literals precisely, which pushed unitree's `self.__sub_node_tab = {}` to dict[type, list[UniNode]] -- a shape the native backend cannot lower, demoting a reachable getter to an abort stub. jac0core: 931 -> 901 errors, none new.
Reverts both inference experiments (1bbd933, 0e8322a) -- each fixed one seal error and caused another, because changing how empty literals are typed changes what the native backend is handed everywhere. The actual problem is one line: `acc_list = []` where acc_list is `list[Ability] | None`. An empty literal cannot take its element type from a union target, so it resolved to Unknown. Accumulating in a plain `list[Ability]` and assigning that keeps None and [] distinct -- which HasVar.accessors relies on -- with no compiler change at all.
The migration bound a fresh `items: list[any] = list(tcs)` to get a usable element type after `isinstance(tcs, list)`, which allocates a copy and is a behaviour change in a typing-only PR. A cast expresses the same intent and leaves the loop iterating the original list.
Collaborator
|
Closing: no activity in 10+ days. Reopen if still relevant. |
This was referenced Sep 7, 2026
main added ~315 commits of code written while a bare dict, list or tuple was still only a warning. Each of those sites is now E1036, so this names the element types across 800 files. The spellings are the behaviour-preserving ones -- a bare generic already resolved as if [any] had been written -- so the migration cannot introduce a new type error. Fixtures that exist to carry a bare generic keep theirs.
…rted E1036 only fires on a decl annotation -- an impl-side one is deduped against it and never reported -- so a migration driven by the checker's output cannot see impl signatures at all. Under the new rule those keep resolving to Unknown, which is what broke native lowering of unitree.jac. Found syntactically instead: 672 sites across 176 files, including the member-lookup path itself and the annotations the last merge reverted. Fixtures that pin a bare-generic count keep theirs.
Its test counts 13 E1118 for unparameterized connect operands, so those operands have to stay bare; typing them made the diagnostic stop firing.
A bare generic inside a type argument, as in dict[(str, dict)], is the case that broke the native kernel build: codeinfo.jac carried one and unitree.jac imports it, so the Unknown propagated through the graph and surfaced far from its cause. jac check on unitree.jac alone passed -- only a whole-graph build type-checks across the import. 154 sites across 65 files. Fixtures pinning a bare-generic count keep theirs.
The hetero matrix fixture exercises the bare List.jacval / Dict.<k>.jacval layout, so its containers have to stay unparameterized; two sites already carried the inline ignore and five did not.
The check gate runs from the repo root, so scripts/ is in its scope.
A bare generic now resolves to Unknown rather than any, so an empty container literal with no annotation, and a value narrowed by isinstance to a bare dict or tuple, both stop being usable. Neither has an annotation for the migration to fix, so each site gets one.
Covers the byllm message helpers, the LLVM binding and wasm linker, the react-native build and the shadcn chart. Two byllm signatures were also wrong rather than merely unparameterized: normalize_tool_result returns Media values, and content_preview joins strings.
A cast is a third annotation position alongside 'name: T' and '-> T', and the earlier passes anchored only on those two, so 'x as list' kept yielding Unknown elements.
isinstance's second argument must stay unparameterized -- a parameterized generic there is a runtime TypeError, which is what broke the scale and runtime suites. Tests also build .jac sources as strings and compile them, so those sources need element types too; fixtures that assert on a bare container keep theirs. The string masking in the detector mis-paired quotes and hid real code, so it now scans in one pass instead.
The cfg tests pin graphviz output, so their fixtures have to keep the spelling the expected labels record. The client codegen sources needed their empty literals named as well -- an unannotated one is Unknown now, so the compile produced no JS at all.
Its premise was that a bare list means list[any]; that is the semantics this change removes, so the control now spells the any it is pinning.
An empty literal with no expected type inferred list[any] before and is Unknown now, so every 'nd.params or []' degraded its union and each later use of an element errored. Typing the literal keeps the previous meaning without constraining the target, which matters where the other operand is a Sequence and a target annotation would mis-type it. 248 sites across 91 files.
The bootstrap translator does not lower an 'as' cast, so the form broke runtime.jac and with it the compiler. Empty literals are handled in the checker instead.
An empty literal with no expected type resolves to Unknown now, so the union degrades and every later use of an element errors. Naming the target restores the element type at 251 sites across 92 files; where the other operand is a Sequence the target is named Sequence, since a list annotation would refuse it.
The seal refused 'Cannot assign <Unknown> | dict to dict[Any, Any]': a .get() on a receiver the checker cannot see returns Unknown, and naming the target a dict refuses that half of the union. These targets take the gradual type the value actually has.
An empty literal with no expected type, and a value narrowed by isinstance to a bare dict or list, both resolve to Unknown now. Naming the target restores what those sites inferred before. Corrects four annotations from the earlier sweep that were simply wrong: a list that stores None, a list of bytes, a Sequence of call params, and a project root that is a pair of paths.
…are-generic-hard-error # Conflicts: # jac/jaclang/compiler/backends/native/impl/na_compile_pass.impl.jac # jac/jaclang/compiler/backends/native/na_compile_pass.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/calls.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/closures.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/container_helpers.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/core.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/dicts.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/enums.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/expr.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/func.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/generics.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/hash_core.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/iterators.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/objects.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/refcount.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/stmt.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/tuples.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/types.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.impl/vtable.impl.jac # jac/jaclang/compiler/backends/native/na_ir_gen_pass.jac # jac/jaclang/compiler/backends/native/primitives_native.jac # jac/jaclang/compiler/driver/pass_driver.jac # jac/jaclang/compiler/driver/schedules.jac # jac/jaclang/compiler/frontend/parser/frontend.jac # jac/jaclang/compiler/native_compiler.jac # jac/jaclang/compiler/native_scope.jac # jac/jaclang/compiler/tests/xbackend_equiv.jac # jac/jaclang/compiler/types/ct_eval.jac # jac/jaclang/compiler/types/fixed_width.jac # jac/jaclang/compiler/types/impl/enum_utils.impl.jac # jac/jaclang/compiler/types/stubcat/locate.jac # jac/jaclang/compiler/types/type_evaluator.impl/evaluator_util_methods.impl.jac # jac/jaclang/compiler/types/type_evaluator.impl/type_evaluator.impl.jac # jac/jaclang/compiler/types/type_evaluator.jac # jac/jaclang/compiler/types/type_utils.jac # jac/jaclang/comptime.jac # jac/tests/compiler/backends/native/test_native_class_const_read.jac # jac/tests/compiler/backends/native/test_native_enum_ctor.jac # jac/tests/compiler/test_jcir_gen_pass.jac # jac/tests/support.jac
The native backend and the jac0 tests arrived from main with bare dict, list and tuple annotations, which are an error on this branch. Types the metadata table the lowering reader builds, so its payloads read as str.
SandeepaHWP
force-pushed
the
feat/bare-generic-hard-error
branch
from
September 8, 2026 20:49
b0578fa to
52b0384
Compare
…are-generic-hard-error # Conflicts: # jac/tests/language/test_console.jac
isinstance narrows to a bare list, so the payload fields came back unknown and none of them satisfied the issue's str parameters. The guard above already proves all five are strings.
dict(os.environ) no longer infers its pair, so every subprocess helper that built a PATH from it lost the string type. Names that, the guide examples the checker now rejects, and the leftover or-empty seams. The cfg and annotation fixtures go back to bare: their tests pin the rendered spelling, and the native list they use has no typed counterpart.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Bare
dict,listandtupleare now a hard error rather than a warning.What changed
A generic written without type arguments used to draw
W1036and quietly behave as ifanyhad been written for its element types. Sodictanddict[str, any]meant the same thing while reading differently, and the checker fedanyinto the native path where a real type would have been cheaper.Writing
dict,listortuplebare is nowE1036, and the bare-generic fallback inbuild_type_var_solutionresolves toUnknowninstead ofany, so values read out of such a container are reported rather than silently accepted.The compiler-side diff is small: 5 files, 25 insertions, 10 deletions. Everything else in this PR is the migration that the new rule forces.
Scoping
Both the diagnostic and the fallback are gated on a shared
BARE_GENERIC_REQUIRED = ["dict", "list", "tuple"].The fallback matters more than it looks. It runs on every generic member lookup, not just annotations, so an unscoped change made this fail:
TemporaryDirectoryis generic and instantiated without type arguments, so its__enter__type variable went unsolved and recovered asUnknown. Scoping keepsanyfor everything outside the three types, soset,frozenset,Queueand unparameterised generic classes are untouched.Nested generics
_check_bare_genericnow descends into type arguments. Previouslylist[dict]anddict[str, dict]emitted nothing, because the outer generic was parameterised, but the inner one still resolved toUnknownand surfaced later as an unrelatedE1055/E1053elsewhere in the file. Three of the harder bugs in this branch traced back to exactly that. There were about 200 such sites.The migration
Roughly 395 annotation sites across ~140 files, typed from how each value is actually used rather than widened to
any:list[float]for flattened 4x4 matrices,list[list[float]]for the matrix stacksdict[int, int]for keycode statedict[str, SemTokManager],tuple[bool, set[tuple[str, str]]],list[tuple[int, int, list[str]]]dict[str, any]only where the payload is genuinely heterogeneous JSONFour failure modes needed more than a substitution:
isinstancenarrowing to a bare generic.isinstance(tcs, list)narrows to barelist, so elements becomeUnknown. Fixed by binding a typed local.list(getattr(...))has nothing to infer from; fixed by annotating the receiving variable..jacignored files.[text] if text else []inferredlist[LiteralString]and rejected a plainstr; restructuring to an explicitiffixed it.The native seal
This was the bulk of the late work and the least obvious part of the change:
.jacignoredoes not protect the native seal. The seal compiles the compiler's own sources into the binary, soE1036there is fatal even in filesjac checkskips entirely, and it fails fast on the first broken module. That surfaced 217 sites across ~25 files injac0corethat no other gate could see, innative_marshal.jac,pyast_gen_pass,codeinfo.jac,runtime.jac,modresolver.jacand the parser.Three of those needed judgement rather than an annotation:
unitree.jac'spy_ast_targetsand thepy_astfield behind it arelist[any], not a concrete AST type: neither module importsast3, and every consumer already casts tolist[ast3.expr], soanypreserves the exact prior behaviour without inventing an import.jir_registry.jacis generated. Typing it by hand made it diverge fromgen_jir_registry.jacand failjac gen-jir-registry --verify, so both annotations moved into the generator. Regenerating now reproduces the committed file byte for byte.parser.impl.jachadacc_list: (list[Ability] | None)assigned an empty literal. An empty literal cannot take its element type from a union target, so it resolved toUnknownonce bare generics stopped defaulting toany. It now accumulates in a plainlist[Ability]and assigns that, which keepsNoneand[]distinct becauseHasVar.accessorsis(Sequence[Ability] | None).That last one is worth flagging to reviewers. Two earlier attempts fixed it in the type evaluator instead, by reading expected container types through unions and then by defaulting uninferrable empty literals to
any. Both fixed the target error and broke a different one, because changing how empty literals are typed changes what the native backend is handed everywhere: the first turnedself.__sub_node_tab = {}into an un-lowerabledict[type, list[UniNode]], the second turnedself.hub = {...} if x else {}into an un-lowerable union. Both are reverted. Type inference is untouched by this PR.Docs
Nine skill guides taught the old style in
jac-fenced examples, whichtest_guide.jacrequires to passjac check. Fifteen sites, several of them nested (list[dict]) or inside a union (dict | None), forms the old warning never reached.Tests
test_checker_pass.jac:bare_generic_hard_error_at_every_site, return type, param type, local annotation, and nested inside a parameterised genericbare_generic_iteration_is_a_hard_error, replacesbare_generic_iteration_yields_any, whose premise this change removesbare_generic_in_signatures_emits_e1036, 6 sites now, down from 8, sincesetandfrozensetare out of scopeexplicit_any_propagates_through_operations, the escape hatch keeps working through chained subscript, attribute access and iterationtest_unpack_any_element.jacwas written on the PEP 484 rule that a baretupleistuple[Any, ...], which is precisely what this change removes. Its fixture now uses the explicittuple[any, any]/list[any]spellings, which reach the same_extract_tuple_element_typepath the test guards.Three native fixtures keep a bare generic behind an inline
# jac:ignore[E1036]with the reason recorded next to it, because there is currently no way to spell what they test. See the next section.prod b1-b5intest_checker_production.jacstill pass; they assert thatUnknownis not a universalany, which this change reinforces.Pre-existing gaps this surfaces
None of these are caused by this PR, and each reproduces on
mainonce the fixture is typed. They matter becauselist[any]is the spelling a barelistmigrates to, and these are the cases it fails on:repr()has no native lowering forlist[any]ordict[str, any], though it lowers bare containers fine.list[any]elements are not reclaimed under--gc none; the nogc churn fixture measured 2.5 MB of growth per window once typed.anycontainer boundUnknown. This one is now fixed upstream by Epic: typed OOP front end. Retire the syntax arena, construct unitree directly, seal the front end natively (supersedes #7970) #8039's follow-up, which made theE1020guard and_extract_tuple_element_typeuseis_any_instance(); it is merged into this branch.The first two share the root that an explicit
anyannotation is aClassTypefor builtinAnyrather than atypes.AnyType, the same split #7961 fixed on the consumer side. Worth deciding whether to close that before or after this lands, since it sets how loud the migration is for people who genuinely hold mixed values.Known gaps
.jacignored files such asna_ir_gen_pass.jacstill carry bare generics thatjac checkcannot see. Those that leak into non-ignored files, or into the native seal, were fixed; the rest are left for follow-up.jac fmtis not idempotent in a single pass on this change: wrapping one signature can push the next one over the line limit, so it needs running until it converges.