Skip to content

feat(checker): require type arguments on dict, list and tuple - #8080

Open
SandeepaHWP wants to merge 51 commits into
jaseci-labs:mainfrom
SandeepaHWP:feat/bare-generic-hard-error
Open

feat(checker): require type arguments on dict, list and tuple#8080
SandeepaHWP wants to merge 51 commits into
jaseci-labs:mainfrom
SandeepaHWP:feat/bare-generic-hard-error

Conversation

@SandeepaHWP

@SandeepaHWP SandeepaHWP commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Bare dict, list and tuple are now a hard error rather than a warning.

What changed

A generic written without type arguments used to draw W1036 and quietly behave as if any had been written for its element types. So dict and dict[str, any] meant the same thing while reading differently, and the checker fed any into the native path where a real type would have been cheaper.

Writing dict, list or tuple bare is now 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.

d: dict = src();
return d["a"]["b"];        # E1036 on the annotation, Unknown through the reads

d: dict[str, any] = src();
return d["a"]["b"];        # fine, and says what it means

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:

with tempfile.TemporaryDirectory() as d {
    p = Path(d) / "x.txt";     # Unknown, and no annotation exists to fix it
}

TemporaryDirectory is generic and instantiated without type arguments, so its __enter__ type variable went unsolved and recovered as Unknown. Scoping keeps any for everything outside the three types, so set, frozenset, Queue and unparameterised generic classes are untouched.

Nested generics

_check_bare_generic now descends into type arguments. Previously list[dict] and dict[str, dict] emitted nothing, because the outer generic was parameterised, but the inner one still resolved to Unknown and surfaced later as an unrelated E1055/E1053 elsewhere 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 stacks
  • dict[int, int] for keycode state
  • dict[str, SemTokManager], tuple[bool, set[tuple[str, str]]], list[tuple[int, int, list[str]]]
  • dict[str, any] only where the payload is genuinely heterogeneous JSON

Four failure modes needed more than a substitution:

  • isinstance narrowing to a bare generic. isinstance(tcs, list) narrows to bare list, so elements become Unknown. Fixed by binding a typed local.
  • Constructor calls. list(getattr(...)) has nothing to infer from; fixed by annotating the receiving variable.
  • Cross-file cascades. Several files that were never edited went red because a bare generic in a different file leaked into them, including from .jacignored files.
  • Inference quirks. [text] if text else [] inferred list[LiteralString] and rejected a plain str; restructuring to an explicit if fixed it.

The native seal

This was the bulk of the late work and the least obvious part of the change: .jacignore does not protect the native seal. The seal compiles the compiler's own sources into the binary, so E1036 there is fatal even in files jac check skips entirely, and it fails fast on the first broken module. That surfaced 217 sites across ~25 files in jac0core that no other gate could see, in native_marshal.jac, pyast_gen_pass, codeinfo.jac, runtime.jac, modresolver.jac and the parser.

Three of those needed judgement rather than an annotation:

  • unitree.jac's py_ast_targets and the py_ast field behind it are list[any], not a concrete AST type: neither module imports ast3, and every consumer already casts to list[ast3.expr], so any preserves the exact prior behaviour without inventing an import.
  • jir_registry.jac is generated. Typing it by hand made it diverge from gen_jir_registry.jac and fail jac gen-jir-registry --verify, so both annotations moved into the generator. Regenerating now reproduces the committed file byte for byte.
  • parser.impl.jac had acc_list: (list[Ability] | None) assigned an empty literal. An empty literal cannot take its element type from a union target, so it resolved to Unknown once bare generics stopped defaulting to any. It now accumulates in a plain list[Ability] and assigns that, which keeps None and [] distinct because HasVar.accessors is (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 turned self.__sub_node_tab = {} into an un-lowerable dict[type, list[UniNode]], the second turned self.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, which test_guide.jac requires to pass jac 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 generic
  • bare_generic_iteration_is_a_hard_error, replaces bare_generic_iteration_yields_any, whose premise this change removes
  • bare_generic_in_signatures_emits_e1036, 6 sites now, down from 8, since set and frozenset are out of scope
  • explicit_any_propagates_through_operations, the escape hatch keeps working through chained subscript, attribute access and iteration

test_unpack_any_element.jac was written on the PEP 484 rule that a bare tuple is tuple[Any, ...], which is precisely what this change removes. Its fixture now uses the explicit tuple[any, any] / list[any] spellings, which reach the same _extract_tuple_element_type path 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-b5 in test_checker_production.jac still pass; they assert that Unknown is not a universal any, which this change reinforces.

Pre-existing gaps this surfaces

None of these are caused by this PR, and each reproduces on main once the fixture is typed. They matter because list[any] is the spelling a bare list migrates to, and these are the cases it fails on:

The first two share the root that an explicit any annotation is a ClassType for builtin Any rather than a types.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 as na_ir_gen_pass.jac still carry bare generics that jac check cannot see. Those that leak into non-ignored files, or into the native seal, were fixed; the rest are left for follow-up.

jac fmt is 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.

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/.
@greptile-apps

greptile-apps Bot commented Aug 12, 2026

Copy link
Copy Markdown
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.
@marsninja

Copy link
Copy Markdown
Collaborator

Closing: no activity in 10+ days. Reopen if still relevant.

@marsninja marsninja closed this Sep 4, 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
SandeepaHWP force-pushed the feat/bare-generic-hard-error branch from b0578fa to 52b0384 Compare September 8, 2026 20:49
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Track: feat(checker): require type arguments on dict, list and tuple (#8080)

3 participants