Bugfix batch August 27 2026 - #2548
Merged
Merged
Conversation
…2499) Two declarations in different modules could mangle to one C name — foo.bar_baz and foo_bar.baz both become foo_bar_baz. The mangled index silently kept the first, so the second definition reached the C compiler as a redefinition. Add E6009, reported after declaration registration, naming both declarations and pointing at the one that would be lost. Colliding functions are marked used so the misattributed unused-function warning no longer fires.
A file's module name is its filename minus .gray, so a local or parameter named after it was resolved as the module: 'b.v' inside b.gray emitted 'b_v' and reached the C compiler as an undeclared identifier. Assigning to it was rejected as a write to a module member. Codegen now takes the module-qualified path only when the qualifier has no value type, and the module-member assignment check skips a qualifier that names something in scope. This also covers a local named after a module that only another file imports.
Instance dispatch compared the self parameter's type name to the struct's
registry key with a raw strcmp. The parameter is written as it appears inside
the declaring module ('Msg') while the key is mangled ('msg_Msg'), so the
receiver was never prepended and the call was rejected for too few arguments.
Resolve the written name in the declaring file's scope and compare the mangled
results, at both the dispatch test and the auto-deref decision. Covers the
value, mutable and pointer receiver forms, and dispatch inside a module file
on its own struct.
…2509) The shadowing cases already covered a parameter in a file that does not import the module it is named after. The reported shape — a parameter in the file that does import it — had no test.
#2508) A module-level declaration is bound in scope under its module's spelling, so the bare-name lookup in check_assign_stmt missed it and 'counter = counter + 1' inside the declaring module was taken for an implicit local declaration. The write went to a shadow local, the module variable kept its initial value, and it was reported as declared but never used. Resolve a bare name through the symbol table before deciding it is unknown. The const-assignment check needs the same lookup, or a write to a module-level constant reaches the C compiler instead of drawing E3005.
…s, enums, and struct functions (#2505)
…2532) size_of() and cast() each hand-rolled a subset of the type grammar, so size_of([int]) and cast(m, map[string:int]) failed with a syntax error before any type checking. Both now use parse_complex_type(), the same parser new() and every type annotation go through. size_of()'s undefined-type check also had to start walking every leaf of a container spelling, since an array types as TK_ARRAY whatever its element names.
The target now goes through parse_complex_type(), which consumes the qualifier; lock that in across enum, alias, and enum-to-int spellings.
) The guard tested the written name against the struct and enum registries, which hold the target name, so `alias Vec2 = Point` slipped past it and fields(Vec2) silently returned an empty [string]. The name now goes through the same resolution a type annotation does; the diagnostic still names the spelling the programmer wrote.
cast() was the one type position with no E4016 check, so the allowlist was left to report an undefined target. It could not: a capitalized name types as TK_STRUCT and came back as an unsupported conversion, and a lowercase one types as TK_UNKNOWN, which skips the allowlist entirely and let the written name reach the C compiler. undefined_type_leaf() also missed a module-qualified leaf. A user module's members are resolved against the symbol table, so a dot that survives resolution names nothing there — only the stdlib carries a qualifier that far down. This corrects the annotation position too: 'mut x lib.Nope' reported a type mismatch and now reports the undefined type.
…ars (#2535) E3100 lived at three call-argument sites, copied verbatim, so a type name reaching any other value position went unreported: the typechecker left it unknown and codegen emitted the written name into the generated C, where the user met a clang error. The check now sits where a label is resolved as an expression, which is the one place every value position passes through, and the three copies are gone. size_of(), type_of() and fields() own their own type-name diagnostics, so their label arguments are skipped in the argument loop the way size_of()'s already were, and type_of() answers a bare primitive from the name itself. resolve_member_expr() no longer resolves a type-name qualifier as a value, which would have reported Color.RED as one. The help text follows what the name reaches: a variant for an enum, an instance for a struct, and the bare message for a primitive or an alias of one.
'private' on a struct or enum is enforced at every use site, and a one-line public alias in the same file went around all of them: the alias was reachable from any importing module as an annotation, a constructor, and for member access. New code E4025, reported at the alias declaration once structs and enums are registered. The target is walked to its leaves, so a container or pointer spelling and a chain through another private alias are caught too. A private alias, and a public alias of a public type, are unaffected.
cast(value, EnumType) stored whatever integer it was given. The result matched no variant afterwards: 'when' fell through to default and every '==' against a real variant was false, and a negative value wrapped through the unsigned representation on the way in. New panic P0107, raised by gray_enum_cast_check(). The variant names are emitted into the check array as written, so the C compiler supplies their values and an explicit-value enum is checked against what it actually declares rather than a position range. A flags enum is a set, so any combination of its bits passes; every other enum admits only the values it declares.
…ource (#2515) E3122 tests Symbol.const_source, which is set where addr() is applied to a const-declared variable. That site looked its operand up with a bare scope_lookup, and a module-level declaration is bound under its module's spelling — so a bare reference from inside the module missed, the pointer went unmarked, and the write through it was accepted. The single-file form of the same code was rejected. The lookup now goes through checker_lookup_symbol, which falls back to the symbol table. Writing through a pointer to a module-level mut, and reading through one to a module-level const, are unaffected.
…m-code chore: remove dead mem.make code and stale mem.size_of comment
) Alias.func() died on E6010. The qualifier of a member call was run through typechecker_resolve_alias(), which resolves import aliases only, so a type alias reached the struct dispatch unresolved and failed its is_struct_name() gate — even though the same alias was accepted as a type annotation and as a constructor in the same file. The qualifier now also resolves as a type alias, and the member label is rewritten to the struct's own name so codegen mangles the call against the struct. This is what the enum-alias branch alongside it already does. A variable of the same name still wins: that spelling is instance dispatch.
Every type parameter was rejected. resolve_call_expr() resolved each argument before resolve_generic_call() could say which positions are type-parameter positions, so a type argument reached the label branch of resolve_expression() and was reported as a type name in a value position. The callee's declaration is now looked up before the arguments are walked, and a label argument landing on a <?> parameter is skipped, leaving resolve_generic_call() as the sole validator for those arguments — the shape it already assumed. An undefined name stays rejected there by E3127.
…2500) A struct literal was only recognised when its type name began with a capital, so every lowercase-named type was unusable: the literal parsed as a bare label and the type name was then reported as a value. The rule was never in STANDARD.md, and it was wider than an underscore prefix — bpoint failed the same way b_Point did. The capital was doing real work, though: it is what kept `if flag {` from reading as a struct literal, because if and while never suppressed literals in their conditions the way when does. They do now, so the name's spelling has nothing left to decide. parse_infix_expression cleared the suppression flag instead of restoring it, which a comparison inside a condition would have used to hand `&& b {` back an enabled flag.
test_error_E3127_sizeof_non_struct asserted the struct-only rule that a type
parameter no longer carries: size_of(t) with identity(int) is a legal call now
and answers 8. It is rewritten against the T{} body form, which is what
narrows a type parameter to struct types and where E3127 is now reported.
Two tests join it: a primitive type argument draws no E3127, and a type
argument that names no type draws E4016.
) A field type written as an alias was resolved only into the field's GrayType; the name left in the AST stayed the alias. Codegen builds both the field's C type and its member accesses from that name, so a field declared with a func alias was emitted as an untyped void * and its call mangled into a struct-function call, producing C that did not compile. Record the resolved name on the field so every later reader sees what the spelled-out form produces.
…2543) The struct-literal field check treats any two func types as assignable, so a reference whose signature did not match the field was only caught when it was assigned to the field afterward, never when the literal supplied it. Run func_types_mismatch on the field value the way the assignment path does.
A file-scope initializer is emitted where it is written, so it can only name declarations already in scope at that point. The module registry vouched for any name it held as a const, which let one declared further down pass the typechecker and reach the C compiler as an undeclared identifier. Function bodies stay exempt, since codegen emits file-scope declarations ahead of them. The test covering this passed anyway: its marker used the /* expect: */ spelling the runner does not read, so it fell through to the branch that only requires the compile to fail somehow, which a C compiler error satisfies. Convert both files using that spelling, and require the no-marker branch to see a Grayscale diagnostic.
Declarations are keyed by their mangled spelling, but a type is written as it is named where it appears. Matching the written name alone found nothing for a struct declared in another module, and every caller reads that as 'not a struct I know': the deep-copy walk reported no heap-backed fields, so a function returning such a struct skipped its escape copy and handed back maps, arrays, and strings pointing into its own destroyed arena. Successive calls reuse the same freed block, so every value one factory returned aliased every other.
gray man, STANDARD.md, and codegen all give alloc(a Arena, value T) -> ^T. The typechecker typed the call as T, so reading the result produced C that does not compile and the documented 'mut p ^int = mem.alloc(a, 42)' was rejected outright. The string and array-literal branches of the codegen path built their value in the target arena and handed it back directly, so the same call returned a pointer or a value depending on what it was given. All three branches now bind the arena once, build the value, and box it. The temp is typed from the value's Grayscale type rather than __auto_type, which deduced C int for a literal and gave a ^int that was not int64_t *.
Five e2e cases annotated the result of mem.alloc as string or [int], the shape the string and array branches used to hand back. alloc returns ^T for every T now, so they read through the pointer.
Declared in error.h and defined in error.c with no callers anywhere in the repo. The code-aware emitters documented directly beneath it — diagnostic_warning_code, diagnostic_warning_code_formatted, and diagnostic_warning_message — superseded it.
The macro had no references anywhere in the repo, including the C source that codegen emits.
Five headers each restated the token name on the line below, which already carried its glyph. Collapsed them under one group header, matching how the rest of the file annotates token groups. The two parentheticals that carried real information — pointer type on TOK_CARET and wildcard type placeholder on TOK_QUESTION — moved onto their lines.
The old name sat one word from codegen's operator_to_c_string while the two return different text for the bitwise operators: codegen emits &, |, ^, ~, << and >>, this one spells them bit_and, bit_or, bit_xor, bit_not, bit_shift_left and bit_shift_right for diagnostics. Reaching for the wrong one produced wrong output with no compiler complaint.
main() carried 631 lines of import resolution: directory scanning, path normalization, cycle and diamond detection, name mangling and AST merging, plus E6001, E6005, E6011 and W2014. Another 156 lines of import cache and directory scan helpers sat above it, used by nothing else. Moved both into parser/imports.c behind imports_resolve(), which returns the file-to-module attribution the type checker needs as a struct rather than through six out-variables threaded down main(). read_file moved to platform as gray_read_file since both callers now need it. main() drops from 1244 lines to 606, main.c from 1706 to 854. No behavior change: the old and new compilers produce byte-identical output across all 1692 integration test files, 129 of them multi-file import tests.
The 14 flags were loose locals declared at the top of main() and threaded through everything below it, so reading any one branch meant scanning back for where its flag was set. They are now fields on a CompilerOptions struct filled by parse_args(), which reports version and help requests as ARGS_DONE and unusable arguments as ARGS_ERROR rather than returning out of main() from inside the parse loop. main() drops from 606 lines to 505. No behavior change: the old and new compilers agree on every flag, including --fmt writeback and the -O, -q and --cc paths, and produce identical output across all 1692 integration test files.
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.
Summary
This PR contains a substantially large batch of typechecker correctness work, centered on the user module system but also contains various other fixes and improvements throughout the compiler, tests, and documentation
Bugfixes
mutvariable (bug: plain assignment to a module-level mut variable is silently dropped #2508)privateacross module boundaries for structs, enums, and struct functions (bug: private is not enforced across a module boundary for structs, enums, and struct functions #2505)privateon bare names fromusingmodules (bug: private module constants and variables are readable by bare name under 'import and use' #2520)addr()on a module-level constant as a const source (bug: writing through a pointer to a module-level constant is not rejected #2515)type_of(bug: type_of() on a cross-module type returns the mangled name #2519)size_oftype argument (bug: size_of() on a module-qualified type emits sizeof(unknown) and leaks a C compiler error #2528)size_of()andcast()(bug: size_of() and cast() reject container type spellings that new() accepts #2532)fields()type-name guard (bug: fields() on a struct alias bypasses the type-name guard and returns an empty array #2525)<?>type arguments (bug: E3100 rejects every <?> type argument, breaking type parameters entirely #2539)mem.alloc(bug: mem.alloc types as its value, not as a pointer to it #2544)mem.allocto its pointer return (bug: mem.alloc types as its value, not as a pointer to it #2544)Performance, Optimizations & Refactors
<?>type argument (refactor: allow any type name as a <?> type argument, not just structs #2540)main()inmain.cadding readability and maintanibiltyCompilerOptionsstruct to enhance maintanibiltyoperator_to_stringtooperator_display_namefor readabilitydiagnostic_warning_helpGRAY_MAX_INLINE_ELEM_SIZETests
Docs & Chores
STANDARD.mdCONTRIBUTING.mdtoken.hmem.makecode thanks @coincoin57660-byte!