Skip to content

Bugfix batch August 27 2026 - #2548

Merged
SchoolyB merged 49 commits into
mainfrom
bugfixes/august-27-2026
Aug 28, 2026
Merged

Bugfix batch August 27 2026#2548
SchoolyB merged 49 commits into
mainfrom
bugfixes/august-27-2026

Conversation

@SchoolyB

@SchoolyB SchoolyB commented Aug 28, 2026

Copy link
Copy Markdown
Member

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

Performance, Optimizations & Refactors

  • Accept any type name as a <?> type argument (refactor: allow any type name as a <?> type argument, not just structs #2540)
  • Extract the import resolver out of main() in main.c adding readability and maintanibilty
  • Parse arguments into a CompilerOptions struct to enhance maintanibilty
  • Rename operator_to_string to operator_display_name for readability
  • Remove unused diagnostic_warning_help
  • Remove unused GRAY_MAX_INLINE_ELEM_SIZE

Tests

  • Several new regression(integration) tests added for squashed bugs

Docs & Chores

  • Fix stale docs in STANDARD.md
  • Update CONTRIBUTING.md
  • Clean various comments in token.h
  • Remove dead mem.make code thanks @coincoin57660-byte!
  • Update the PULL_REQUEST_TEMPLATE.md

coincoin57660-byte and others added 30 commits August 26, 2026 15:23
…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.
…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.
@github-actions github-actions Bot added documentation Improvements or additions to documentation lexer Related to tokenization and lexical analysis parser Related to parsing and AST construction typechecker Related to type checking and validation module-system Related to imports, exports, and module loading stdlib General standard library issues tests Related to unit tests or test infrastructure error-messages Related to improving error messages cli Command-line interface related grayc Related to the grayscale compiler core or its internal tooling labels Aug 28, 2026
@SchoolyB SchoolyB changed the title Module system: cross-boundary name resolution, visibility, and alias fixes Bugfix batch August 27 2026 Aug 28, 2026
@SchoolyB
SchoolyB merged commit 5593456 into main Aug 28, 2026
@SchoolyB
SchoolyB deleted the bugfixes/august-27-2026 branch August 28, 2026 02:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli Command-line interface related documentation Improvements or additions to documentation error-messages Related to improving error messages grayc Related to the grayscale compiler core or its internal tooling lexer Related to tokenization and lexical analysis module-system Related to imports, exports, and module loading parser Related to parsing and AST construction stdlib General standard library issues tests Related to unit tests or test infrastructure typechecker Related to type checking and validation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants