Skip to content

Add type class bounds for T: generics - #1226

Merged
Frotty merged 17 commits into
masterfrom
feature/typeclass-bounds
Aug 14, 2026
Merged

Add type class bounds for T: generics#1226
Frotty merged 17 commits into
masterfrom
feature/typeclass-bounds

Conversation

@Frotty

@Frotty Frotty commented Aug 14, 2026

Copy link
Copy Markdown
Member

Adds type class bounds to T: generics, so a generic can require operations of the type it is bound to instead of only storing and returning values. This is what FastHashMap<K:, V:> needs: a way to say "K can be turned into an integer" without forcing K to be a class.

public interface Indexable<T:>
    function toIndex(T x) returns int
    function fromIndex(int i) returns T

implements Indexable<vec2>
    function toIndex(vec2 v) returns int
        ...
    function fromIndex(int i) returns vec2
        ...

class HashMap<K: Indexable, V: Indexable>
    function put(K key, V value)
        saveInt(K.toIndex(key), V.toIndex(value))
    function get(K key) returns V
        return V.fromIndex(loadInt(K.toIndex(key)))

Design

A bound is satisfied by an instance, not by subtyping. An upper bound in the Java sense would have been useless here: the types that matter most (int, real, string, tuples, handle types) can never implement an interface, and boxing them would reintroduce exactly the indirection T: generics exist to remove. HashMap<int, V> is the central case, and it works.

Requirements are called on the type parameter, K.toIndex(key), not on the value. Because there is no receiver, a requirement which produces a value of the type — fromIndex — needs no special form; it is just another function. This is also why no Self type is required.

Bounds reuse the syntax that was already in the grammar. typeParamConstraints has parsed <T: A and B> since new generics landed; the constraint list was simply ignored. A bound names the interface unapplied, so <K: Indexable> reads as "there is an instance of Indexable<K>".

Instances are coherent by construction. An instance of I for X may only be declared in the package declaring I or the package declaring X, and only once. Resolution is therefore a direct lookup in two known packages: no search over the import graph, no divergence detection, and no way for I for X to mean different things in different files. This is a deliberate departure from the implicit-search design in #931, which allowed conflicting instances to coexist and made a TreeSet<string> from one package incompatible with another's.

No new reserved word. An instance keyword was the obvious spelling and it broke the standard library, which uses instance as a local in TimerUtils.wurst. Softening the token would mean threading it through every name=ID site in the grammar, so instances are declared with the existing implements keyword, which is unambiguous at package level. The unreachable typeclass rule is removed at the same time, which frees a word that was reserved for nothing.

Lowering

Resolution is entirely static; nothing is searched at runtime.

  • Jass — the existing monomorphiser already keyed specialisation on ImTypeArgument.typeClassBinding, so filling that binding in is enough. Each dispatch becomes a plain call to the instance function.
  • Lua — generics stay erased, and targeted specialisation, which previously ran only for generic construction, now also triggers on dispatch. A bounded generic lowers to a direct call here too, rather than needing a runtime dictionary.
  • Interpreter — compiletime code runs before generic elimination, so dispatch resolves through the calling frame's type arguments.

A bounded generic passing its own parameter to another one has no instance at the inner call site, since the parameter is abstract there. Both the specialiser and the interpreter take it from the frame that supplied it, so chains of bounded generics still resolve statically.

This adds no node to any .parseq sum type in the IM: ImTypeVarDispatch, ImTypeClassFunc and ImTypeArgument.typeClassBinding were already present and unused. The only new AST node is the InstanceDecl entity.

Diagnostics

Unsatisfied bound (reported where the type argument is chosen, naming both the type and the bound), orphan instance, duplicate instance, incomplete instance, a method not required by the interface, and an interface with the wrong number of type parameters used as a bound.

Scope

Single-parameter interfaces only. Multi-parameter type classes and dependent bounds are deliberately excluded: they need functional dependencies or associated types to infer, which is why the equivalent example in #931 only worked with all type arguments spelled out explicitly. Instances have no type parameters of their own, so "every List<T> is Indexable when T is" cannot yet be written.

No compiler-derived instances. #931 hardcoded a list of blessed interface names and auto-derived instances for class types; that runs against the principle set out in AGENTS.md §9 for the serialization surface, where the compiler holds no knowledge of specific library names. Retiring the type-parameter castTo int sites in HashMap, HashList, HashSet and Table needs one explicit instance declaration per type instead.

Testing

19 new tests in TypeClassTests: dispatch on classes and primitives, several instances of one interface, multiple bounds, an interface with two requirements, transitive bounds, distinct specialisations per type, Lua execution, and each diagnostic above. dispatchLowersToDirectCallLua reads the emitted Lua and asserts the instance function is called directly, with no dispatch node and no dictionary, so the performance claim is pinned rather than asserted.

Full suite: 1598 tests, 0 failures.

Frotty added 9 commits August 14, 2026 11:14
Introduces a top-level 'instance Iface<Type>' entity which binds a generic
interface to one concrete type. The dead 'typeclass' grammar rule is removed,
so this adds no net reserved word.

- Wurst.g4: instanceDef rule, replaces unreachable typeclassDef
- wurstscript.parseq: InstanceDecl node on WEntity, WScope and HasModifier
- attribute hooks: NameLinks, TypeNameLinks, ReadVariables, DescriptionHtml,
  PrettyPrinter, TLDTranslation
- extend the exhaustive matchers broken by the new WEntity alternative
TypeClassConstraints reads the interfaces named by a <T: Iface> bound, requiring
a single-parameter interface referenced without type arguments.

TypeClassInstances resolves a (interface, type) pair to its instance by looking
only in the package declaring the interface and the package declaring the type.
Restricting instances to those two packages keeps selection independent of the
import graph, so a pair always resolves to the same instance program-wide.
A new 'instance' keyword reserved a plausible identifier and broke the standard
library, which uses it as a local (TimerUtils.wurst:65). Softening the token
would mean threading it through every name=ID site in the grammar, so reuse the
existing 'implements' keyword at package level instead: it is unambiguous there,
since no other top level entity can start with it, and costs no reserved word.
Makes T.f(x) type check inside a generic constrained by <T: Iface>:

- WurstTypeTypeParam gains a static-ref form which exposes the methods required
  by its bounds, substituting the interface's type parameter by T
- FuncLink.withReceiverType re-points a requirement at the type parameter, so the
  requirement resolves on T rather than on an interface instance
- name resolution accepts a bounded type parameter in receiver position, only
  after ordinary variable lookup fails, so it can never shadow a real variable
- such a call takes no implicit 'this': the value is an ordinary argument
Completes the path from T.f(x) to executable code:

- call sites of a bounded generic record, per type argument, the instance function
  implementing each requirement (the typeClassBinding slot that already existed on
  ImTypeArgument but was never filled)
- generic elimination replaces a dispatch with a plain call to that function once
  the type variable is substituted, so a bounded generic costs no more than a
  hand written monomorphic call
- the interpreter resolves dispatch through the calling frame's type arguments,
  since compiletime code runs before generic elimination
- flattening treats a dispatch like any other call instead of rejecting it

Type variables for one source type parameter can be distinct nodes, so the
lookup matches on name, as the surrounding pass already does.
Diagnostics: unsatisfied bound at the point the type argument is chosen, plus
orphan, duplicate, incomplete and not-required checks on an instance declaration.

A bounded generic passing its own parameter to another one has no instance at the
inner call site, since the parameter is abstract there. Both the specialiser and
the interpreter now take it from the frame which supplied it, so chains of bounded
generics still resolve statically.

Also fixes a regression where probing for a type parameter receiver swallowed the
ambiguity error for an ordinary name: the normal lookup stays the last word, and
the probe is gated on receiver position so other lookups keep their original cost.
Lua keeps generics erased and only specializes paths which need the concrete type
argument. Type class dispatch is such a path, so it now triggers specialization
alongside generic construction. A bounded generic therefore lowers to a direct
call on Lua as well, rather than needing a runtime dictionary.

Renames the predicate accordingly, since it is no longer only about construction.
An unsatisfied bound was only reported when the return type happened to mention
the type parameter, which is rarely the case. Check the call signature instead,
since that is where the inferred type arguments and their bounds are both known.

Test corpus covers dispatch on classes and primitives, several instances of one
interface, multiple bounds, two requirements, transitive bounds, distinct
specialisations per type, Lua execution, and the diagnostics for unsatisfied,
duplicate, incomplete, orphan and over-specified instances. A codegen assertion
pins the performance claim: the emitted Lua contains a direct call and no
dispatch or dictionary.
Adds the feature to the shipped language digest and the changelog, including the
call form, the orphan and uniqueness rules, and the fact that a bound costs
nothing at runtime.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aa9558bd5f

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +2681 to +2682
if (provided.getName().equals(requirement.getName())) {
found++;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Validate each instance method against its requirement

Matching only by name accepts an instance method with the wrong parameter count/types or return type. For example, implements ToIndex<int> with function toIndex(string x) passes Wurst validation, is selected by findImplementation, and emits a Jass call that pjass rejects because an integer is passed to a string parameter. Compare the implementation's substituted signature with the interface requirement before counting it as supplied.

AGENTS.md reference: AGENTS.md:L77-L79

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in de966e2. Instance methods are now compared against the requirement with the interface's type parameter replaced by the instance type, covering parameter count, parameter types and return type, and the error reports the expected signature. Tests: instanceMethodWithWrongParameterTypeIsRejected, instanceMethodWithWrongParameterCountIsRejected, instanceMethodWithWrongReturnTypeIsRejected, plus instanceMethodMatchingSubstitutedRequirementIsAccepted to pin that substitution is what is matched.

Comment on lines +2603 to +2606
if (typ instanceof WurstTypeUnknown || typ instanceof WurstTypeTypeParam
|| typ instanceof WurstTypeBoundTypeParam) {
// still abstract, or already broken: checked once it is bound to something concrete
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Reject abstract arguments that do not carry the required bound

Returning for every abstract type argument also accepts calls where the caller's type parameter does not provide the callee's bound. A focused repro where outer<R:>(R x) calls inner<Q: Show>(x) validates, but outer(42) throws No type class instance bound for show on type argument R in the interpreter even when Show<int> exists, because the outer frame has no binding to inherit. Either require the abstract argument to declare the needed bound here or resolve its concrete instance during propagation.

AGENTS.md reference: AGENTS.md:L84-L85

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in de966e2. An abstract type argument now has to declare the bound itself; if it does not, the call is rejected where the argument is chosen rather than failing later with no instance to dispatch to. Your repro is the test unboundedTypeParameterCannotSatisfyBound, and boundedTypeParameterSatisfiesBound covers the same shape once outer declares <R: Show>.

I took requiring the bound rather than resolving a concrete instance during propagation: with the outer parameter still abstract there is no single instance to resolve to, since the outer generic may later be instantiated at several types.

Comment on lines +34 to +36
InterfaceDef def = resolveBound(e);
if (def != null) {
result.add(def);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Diagnose invalid type-class bounds

Bounds that resolve to a class, an applied interface, or an interface with the wrong arity are silently omitted here, despite the comment claiming that the validator reports them separately. No such validation exists: for example, function foo<Q: Marker> where Marker is a class compiles and permits foo(1), making the declared constraint a no-op. Report the invalid bound at its source instead of dropping it.

AGENTS.md reference: AGENTS.md:L84-L85

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in de966e2. invalidBoundReason now explains why a bound is unusable and the validator reports it at the bound, so the comment and the behaviour agree. Covered by classAsBoundIsRejected (your repro), appliedInterfaceAsBoundIsRejected and multiParameterInterfaceAsBoundIsRejected.

Checking this also surfaced a related hole you did not hit: an interface that extends another was accepted as a bound, but requirements are collected from the interface's own functions, so inherited ones were silently dropped. Extending interfaces are now rejected at both the bound and the instance rather than half-supported — see extendingInterfaceAsBoundIsRejected.

…d bounds

Instance methods were matched by name only, so an implementation with the wrong
parameter types, parameter count or return type was accepted and emitted, leaving
the error to pjass. Compare against the requirement with the interface's type
parameter replaced by the instance type, and report the expected signature.

A type argument which is itself a type parameter was accepted unconditionally.
That let a generic without the bound pass its parameter to one that needs it: the
call type checked but had no instance to dispatch to at runtime. Such an argument
now has to declare the bound itself.

Bounds which do not name a usable type class were silently dropped, so a bound on
a class or an applied interface became a no-op. They are reported at the bound.

Interfaces which extend another interface are rejected as bounds, at the bound and
at the instance: the requirements of a bound are the interface's own functions, so
allowing inheritance would silently omit the inherited ones.
@Frotty

Frotty commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

@codex review

All three findings are addressed in de966e2:

  • P1 instance signatures — implementations are compared against the requirement with the interface's type parameter substituted by the instance type, covering parameter count, parameter types and return type.
  • P1 abstract type arguments — an argument that is itself a type parameter must now declare the bound; your outer<R:> calling inner<Q: Show> repro is rejected at the call rather than failing in the interpreter.
  • P2 invalid boundsinvalidBoundReason reports non-interface, applied and wrong-arity bounds at the bound itself, so the code matches what the comment claimed.

Checking the last one also surfaced a related hole: an interface extending another was accepted as a bound while requirements were collected only from its own functions, silently dropping the inherited ones. Extending interfaces are now rejected at both the bound and the instance rather than half-supported.

10 new tests (29 in TypeClassTests). Full suite: 1608 tests, 0 failures.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: de966e2c32

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +66 to +68
List<ImTypeVar> typeVars = f.getTypeVariables();
if (typeArgs.isEmpty() || typeVars.isEmpty()) {
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Bind owning-class parameters when entering methods

When a bounded type parameter belongs to a generic class rather than the method itself, f.getTypeVariables() does not include that parameter, so this returns without recording the class argument. For example, calling new Box<int>().get(42) where class Box<T: Show> and get evaluates T.show(...) fails in test().executeProg() with No type argument bound for T; the same gap affects compile-time interpreter execution of the documented bounded-class pattern. Include the owning class's type variables and the receiver's arguments when binding a method invocation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 5bc6ee6, and this turned out to be four separate causes rather than a missing binding step. Thanks for the repro — it was exactly right, and it exposed that the class case was broken end to end.

  1. Instances travelled on ImTypeArgument, an argument position, while substituteType returns a plain ImType. They therefore could not survive substitution, which is precisely what happens when a generic class type flows through a return type or a receiver. Since selection is static, implementations are now registered per requirement and concrete type and looked up from the type at the dispatch.
  2. GenericTypes.equals compared those bindings while hashCode had always hashed types alone. Besides violating the contract, it made one request with instances and another without into different keys, so the class was specialised twice: the object was allocated against one copy and dispatched against the other.
  3. Your point directly: a generic class holds its type variables on the class, not its functions, so a constructor or method is called with arguments it has no variable of its own to bind. The interpreter now binds the owning class's variables too, mirroring how type substitutions were already built.
  4. Lua erases generics and specialises only what needs a concrete type argument; a bound cannot be expressed by erasure at all, so a program using one now gets the same full elimination as Jass.

Covered by boundOnGenericClass, boundOnGenericClassWithField, boundOnGenericClassTwoTypes and boundOnGenericClassLua.

Comment on lines +2729 to +2732
for (FuncDef provided : decl.getMethods()) {
if (provided.getName().equals(requirement.getName())) {
found++;
checkInstanceMethodSignature(provided, requirement, binding, iface);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Match overloaded requirements by signature

When an ordinary interface overloads a requirement name, every provided overload is counted and checked against every same-named requirement here. A valid instance implementing convert(T) and convert(int, T) is therefore rejected because the two-parameter implementation is compared against the one-parameter requirement (and found > 1 is also reported). Match each requirement to the implementation with its substituted parameter and return signature; the lowering's name-only findImplementation must select the same match.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in de966e2. Each requirement is now paired with the implementation matching its substituted signature, and validation and lowering share one TypeClassInstances.findImplementation, so a rejected instance and a selected implementation cannot disagree — which was the part of your comment I most wanted to close. Covered by overloadedRequirementsAreMatchedBySignature (your convert(T) / convert(int, T) case) and missingOverloadIsRejected.

class HashMap<K: Indexable, V: Indexable> is the shape the feature exists for, but
a bound on a class did not work. Four distinct causes, each fixed at its source.

An instance travelled on ImTypeArgument, which is an argument position, while
substituteType returns a plain ImType. The instances therefore could not survive
substitution, which is exactly what happens when a generic class type flows
through a return type or a receiver. Since selection is static, implementations
are now registered per requirement and concrete type and looked up from the type
at the dispatch, with the per-argument binding kept as a fast path.

GenericTypes.equals compared those bindings while hashCode had always hashed the
types alone. Beyond violating the contract, it made one specialisation request
with instances and another without into different keys, producing two specialised
classes: the object was allocated against one and dispatched against the other.
Identity now rests on the types.

A generic class holds its type variables on the class, not on its functions, so a
constructor or method is called with arguments it has no variable of its own to
bind. The interpreter now binds the owning class's variables too, mirroring how
type substitutions were already built.

Lua erases generics and specialises only what needs a concrete type argument. A
bound cannot be expressed by erasure at all, so a program using one now gets the
same full elimination as Jass. That also removes the need to prune leftovers.

Lua identifiers: the backend assumed intermediate names were already valid Lua,
which held only because it had never seen a specialised generic. It now sanitises
names itself rather than expecting earlier passes to know about Lua.

The IM printer shows the instances carried by a type argument, and the internal
dispatch error names the type variable and enclosing function; both were needed
to find the above and are worth keeping.
@Frotty

Frotty commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

@codex review

Both findings addressed; the P1 was the more serious of the two and needed root fixes rather than plumbing.

P1, bounds on a generic class (5bc6ee6) — four distinct causes:

  • instances rode on ImTypeArgument, an argument position, while substituteType returns a plain ImType, so they could not survive substitution. Implementations are now registered per (requirement, concrete type) and resolved from the type at the dispatch.
  • GenericTypes.equals compared those bindings while hashCode hashed types alone: a contract violation which specialised the class twice, allocating the object against one copy and dispatching against the other.
  • a generic class holds its type variables on the class, not its functions, so the interpreter bound nothing on entry to a constructor or method.
  • Lua erases generics; a bound cannot be expressed by erasure, so a program using one now gets the same full elimination as Jass.

Also fixed while chasing it: the Lua backend assumed intermediate names were already valid Lua identifiers, which held only because it had never seen a specialised generic. It sanitises names itself now.

P2, overloaded requirements (de966e2) — requirements pair with the implementation matching their substituted signature, and validation and lowering share one selector so they cannot disagree.

Tests: 35 in TypeClassTests (was 19), covering class bounds with and without state, at two type arguments, and on Lua. Full suite: 1614 tests, 0 failures.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5bc6ee623b

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +880 to +885
if (containsTypeClassDispatch()) {
// A type class bound is resolved by monomorphisation: the implementation is chosen per
// concrete type argument. Erasure cannot express that, so a program which uses a bound
// gets the same full elimination as the Jass target rather than the targeted pass.
beginPhase(2, "Eliminate generics for type class dispatch");
new EliminateGenerics(getImTranslator(), getImProg()).transform();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep inherited generic classes valid during Lua elimination

On the Lua target, any type-class dispatch now selects full generic elimination, but that pass leaves dangling inherited method references. A valid repro with class Parent<T: Show> defining render, class Child extends Parent<int>, and new Child().render(1) fails during Lua compilation in LuaTranslator.assertNoDanglingFunctionReferences with dangling reference to removed function 'destroyChild'. Either the elimination pass must update inherited method implementations consistently or Lua must specialize only the paths needed for type-class dispatch; otherwise bounded generic inheritance cannot be compiled to Lua.

AGENTS.md reference: AGENTS.md:L217-L221

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0dbce7b. You were right that routing Lua through the full elimination was the wrong call: that pass is designed to be followed by class elimination, which the Lua pipeline never runs, so it left exactly the dangling inherited references you saw. Reverted, and the gap that motivated it is fixed at its source instead.

Of the two options you offered I took the second, specialising only the paths that need it, with two fixes:

  • Constructing a class whose members dispatch must itself be specialised. Otherwise the constructor keeps a generic result type and a method call on that result never becomes concrete enough to resolve, which is why nothing was collected before.
  • A method called directly on a freshly constructed receiver takes its instantiation from the construction, since the receiver's declared type is still generic at that point. Collection repeats until it settles and is idempotent, so it converges in two passes.

Your repro also exposed a second, unrelated bug on the Jass side: a subclass fixing its parent's parameter left the interpreter with nothing to bind, because the receiver carries no type arguments of its own and the concrete type lives on the supertype chain. Both are covered by boundInheritedFromGenericParent and boundInheritedFromGenericParentLua.

One further consequence worth flagging: the second fix turns out to retire an existing restriction in AGENTS.md section 9, which told callers that a generic-construction method on a freshly constructed receiver was unsupported on Lua and to bind a typed local first. Verified against master — new Loader<State>().load() fails there with a dangling reference to the construction marker and passes here — so the guidance is corrected and the shape is now covered in FieldIterationTests.

Full suite: 1617 tests, 0 failures.

Frotty added 2 commits August 14, 2026 16:02
A subclass may fix its parent's bounded parameter, as in class Child extends
Parent<int>. The receiver then carries no type arguments of its own, so the
interpreter found nothing to bind; it now walks the supertype chain, which is
where the concrete type is recorded.

The previous commit routed Lua through the full generic elimination when a bound
was used. That was wrong: the pass is designed to be followed by class
elimination, which the Lua pipeline never runs, so it left inherited method
references dangling and the backend's own invariant check rejected them. Lua is
back on targeted specialisation, with the gap that motivated the change fixed at
its source instead: constructing a class whose members dispatch must itself be
specialised, otherwise the constructor keeps a generic result type and a method
call on that result never becomes concrete. Collection repeats until it settles,
and is idempotent so it settles in two passes.

A method called directly on a freshly constructed generic receiver takes its
instantiation from the construction, since the receiver's declared type is still
generic at that point.
…eiver

Taking a member call's instantiation from the construction that produced its
receiver also fixes the generic-construction case, which is the same code path.
Verified against master: 'new Loader<State>().load()' fails there with a dangling
reference to the construction marker, and passes here.

AGENTS.md section 9 told callers to work around this by binding the receiver to a
typed local first. That guidance is now wrong, so it says the shape is supported
and why. Added to FieldIterationTests, the suite that section nominates, so the
restriction cannot quietly come back.
@Frotty

Frotty commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

@codex review

Addressed in 0dbce7b and 8c9198d. Your diagnosis was correct on both counts, and it turned up a second bug plus a stale restriction elsewhere in the repo.

Lua strategy was wrong. Routing Lua through the full elimination assumed that pass leaves generally valid IM; it does not, because it expects class elimination to follow and the Lua pipeline never runs it. Reverted to targeted specialisation, with the gap fixed at its source: constructing a class whose members dispatch must itself be specialised, or the constructor keeps a generic result type and no method call on that result becomes concrete. Collection repeats until it settles and is idempotent, so it converges in two passes.

A second bug your repro exposed, on Jass. A subclass fixing its parent's parameter left the interpreter with nothing to bind: the receiver carries no type arguments of its own, and the concrete type lives on the supertype chain, which is now walked.

A stale restriction retired. Taking a member call's instantiation from its construction also fixes the generic-construction case, since it is the same code path. AGENTS.md section 9 told callers that shape was unsupported on Lua and to bind a typed local first. Verified against master: new Loader<State>().load() fails there with a dangling reference to the construction marker, passes here. Guidance corrected, and the shape is covered in FieldIterationTests so it cannot drift back.

Tests: TypeClassTests 37, FieldIterationTests 43. Full suite: 1617 tests, 0 failures.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8c9198d0d8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

binding.putIfAbsent(classVars.get(i), inheritIfStillAbstract(globalState, classArgs.get(i)));
}
for (ImClassType superType : classDef.getSuperClasses()) {
bindClassTypeArguments(globalState, binding, superType, visited);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Substitute child arguments before traversing parent types

When a generic subclass forwards its parameter, such as class Child<U: Show> extends Parent<U>, and Child<int> invokes an inherited bounded method, this recurses into the raw Parent<U> type without applying the already-known U -> int binding. A focused testAssertOkLines(true, ...) repro fails with No type class instance bound for show on type argument U, while the equivalent Lua execution succeeds; specialize each supertype using the current class arguments before recursing.

AGENTS.md reference: AGENTS.md:L217-L221

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 0db9fa0, and your diagnosis was exactly right: the supertype is written in terms of the subclass's variables, so descending into the raw Parent<U> bound the parent's parameter to the variable U rather than to what Child was instantiated with.

Each supertype's arguments are now resolved against the current class's before descending, as you suggested. I verified in both directions rather than assuming: without the change your repro fails with No type class instance bound for show on type argument U846, and with it it passes.

I also added a two level case, class Bottom<B: Show> extends Middle<B> over class Middle<M: Show> extends Top<M>, because a fix that substituted only once would have passed the single level test by accident. That one fails without the change too, with M86. Both are covered on Jass and on Lua: boundForwardedByGenericSubclass, boundForwardedByGenericSubclassLua, boundForwardedThroughTwoLevels.

Full suite: 1620 tests, 0 failures.

A generic subclass may forward its own parameter, as in
class Child<U: Show> extends Parent<U>. The supertype is written in terms of the
subclass's variables, so descending into the raw Parent<U> bound the parent's
parameter to the variable U rather than to what Child was instantiated with, and
the dispatch had no instance. Each supertype's arguments are now resolved against
the current class's before descending, so the substitution composes through any
depth of forwarding rather than applying once.

Covered on both targets, with a two level case so that composing is tested and
not just a single substitution.
@Frotty

Frotty commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

@codex review

Addressed in 0db9fa0. A generic subclass forwarding its own parameter (class Child<U: Show> extends Parent<U>) descended into the raw Parent<U>, binding the parent's parameter to the variable rather than to the instantiation. Supertype arguments are now resolved against the current class's before descending, so the substitution composes through any depth of forwarding.

Verified both ways: your repro fails without the change (type argument U846) and passes with it. I added a two level case as well, since a fix that substituted only once would have passed the single level test by accident — it fails without the change too (M86).

TypeClassTests is now 40, covering class bounds fixed by a subclass, forwarded by a generic subclass, and forwarded through two levels, on both targets. Full suite: 1620 tests, 0 failures.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0db9fa06f9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +135 to +137
TypeDef typeDef = node.lookupType(varName, false);
if (!(typeDef instanceof TypeParamDef tp) || !TypeClassConstraints.hasBounds(tp)) {
return null;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve bounded module parameters through expansion

When a generic module declares a bound such as module M<T: Show> and a class uses M<int>, any T.show(x) inside the module method is re-resolved in the copied module-instantiation scope; node.lookupType("T") here returns null, so the compiler emits Could not find variable T before either backend. This makes type-class bounds unusable with existing generic modules; preserve the original bound/type-parameter link or rewrite the type receiver during module expansion.

AGENTS.md reference: AGENTS.md:L217-L221

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and addressed in 50b8413, though not by supporting it. The cause is as you say: using a module copies its body into the class and substitutes the module's type parameters in type positions, but a requirement is called on the parameter as an expression, so it survives the copy and no longer resolves.

Supporting it needs either the copy to rewrite those receivers, or type parameters on ModuleInstanciation, which has none — a parseq change with the matcher fallout that implies. That is more than this change should carry, so bounds on a module type parameter are now rejected with a message that says what is wrong and what to do instead, rather than failing later as an unknown name. Covered by boundOnGenericModule.

Worth doing properly as a follow-up: modules are common enough in container code that the combination will come up.

Comment on lines +83 to +85
ImTypeVars classVars = owner.getTypeVariables();
for (int i = 0; i < Math.min(classVars.size(), typeArgs.size()); i++) {
binding.put(classVars.get(i), inheritIfStillAbstract(globalState, typeArgs.get(i)));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not overwrite receiver type arguments with method arguments

When class Box<T: Show> has an independently generic method first<U:>(...), calling it on Box<int> with U = string first records T = int from the receiver, but this loop then binds the owning class variables from the method-call arguments and overwrites T with string. Consequently T.show(...) dispatches through Show<string>—silently producing the wrong result if that instance exists, or throwing otherwise—during interpreter and compile-time execution; ordinary methods must retain the receiver's class mapping and bind only their own method parameters.

AGENTS.md reference: AGENTS.md:L106-L110

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 50b8413, and this was the serious one of the three: not just an error but a silently wrong result wherever the bogus instance exists. Reproduced exactly as you described — describe<string> on a Box<int> bound the class's T to string.

The cause was mine, from the class-bounds work: the owning class's variables were bound positionally from the method call's type arguments. That is only correct when the arguments really are the class's, which is the constructor case that motivated it. It is now gated on the function having no type parameters of its own, and the receiver's mapping is never overwritten. Covered by classBoundWithIndependentMethodTypeParam.

for (int i = 0; i < expectedParams.size(); i++) {
WParameter actual = provided.getParameters().get(i);
WurstType expected = expectedParams.get(i);
if (!actual.attrTyp().equalsType(expected, actual)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Alpha-rename generic requirement parameters before matching

When a requirement itself is generic, for example function pair<U:>(T x, U y) returns U, a structurally identical instance implementation is rejected with the contradictory diagnostic that U was expected but U was provided. The comparison uses the distinct TypeParamDef identities from the interface method and implementation without first pairing their method type parameters, so no instance can satisfy such a requirement; map equal-position method parameters—including arity and constraints—before comparing signatures, and carry that mapping through the shared implementation matcher.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 50b8413. The diagnostic was indeed self-contradictory: the comparison used the interface's and the implementation's distinct TypeParamDef definitions, so an identical signature was rejected with a mismatch between U and itself.

Rather than add the pairing, this version rejects a requirement that carries its own type parameters, with a reason that is actually true. Alpha-renaming plus arity and constraint matching, threaded through the shared implementation matcher as you describe, is the right fix if the shape is ever needed; nothing requires it yet, and a wrong-but-confident diagnostic seemed worse than an honest restriction. Covered by genericRequirement.

…clearly

A method with its own type parameters was binding the owning class's variables
positionally from the method call's type arguments, overwriting the mapping taken
from the receiver. Calling describe<string> on a Box<int> therefore dispatched the
class's bound through string, which is wrong wherever that instance exists and
silently so. The owning class is now bound only when the function has no type
parameters of its own, which is the constructor case that needed it, and the
receiver's mapping is never overwritten.

Two shapes that cannot work yet reported misleading errors rather than saying so:

A bound on a module type parameter failed with an unknown name. Using a module
copies its body into the class and substitutes type parameters in type positions,
but a requirement is called on the parameter as an expression, so it survives the
copy and no longer resolves. Supporting it needs the copy to rewrite those
receivers, or type parameters on the instantiation, which it has none of.

A requirement carrying its own type parameters was compared using the interface's
and the implementation's distinct definitions, so an identical signature was
rejected with a mismatch between a name and itself. Matching those would mean
pairing them first, which this version does not do.
@Frotty

Frotty commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

@codex review

All three addressed in 50b8413. One was a genuine defect, two were misleading diagnostics over real limits.

Method type arguments overwriting the receiver's class binding was the serious one, and it was mine: the owning class's variables were bound positionally from the method call's type arguments, so describe<string> on a Box<int> dispatched the class's bound through string — wrong, and silent wherever that instance exists. Now bound only when the function has no type parameters of its own, which is the constructor case that needed it, with the receiver's mapping never overwritten.

Bound on a module type parameter is rejected with an accurate message instead of an unknown name. Your diagnosis is right: the copy substitutes type parameters in type positions, while a requirement is called on the parameter as an expression, so it survives into a scope that no longer declares it. Supporting it needs receiver rewriting during expansion or type parameters on ModuleInstanciation, which has none; that is follow-up work, and modules are common enough in container code that it is worth doing.

Generic requirement likewise rejects with a true reason rather than reporting a mismatch between a name and itself. Pairing the method type parameters is the right fix if the shape is ever needed.

TypeClassTests is now 43.

@Frotty

Frotty commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Full suite on 50b8413: 1623 tests, 0 failures.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 50b8413984

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +206 to +210
if (typeArgumentsContainTypeVariable(call.getTypeArguments())) {
// The receiver's declared type is still generic, which happens when the method is
// called straight on a freshly constructed value. The construction states the
// instantiation, so take the arguments from it.
useConstructionTypeArguments(call);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject mixed class/method generics before specializing Lua

On the Lua backend, a bounded generic class method that declares an independent type parameter—such as Box<T: Show>.describe<U:>(...)—reaches this branch with only the explicit U argument. Because that argument is already concrete, receiver recovery is skipped and the specializer receives one argument while expecting both T and U, aborting with the internal diagnostic Generics should match class method type variables. Since this shape is intentionally unsupported, reject it with a clear target-specific diagnostic before lowering rather than failing inside generic specialization.

AGENTS.md reference: AGENTS.md:L330-L332

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reproduced, and I reached a slightly different conclusion, so the reasoning is worth stating.

The shape does fail on Lua as you describe. What it does not do is produce a wrong result: I checked that specifically by adding an implements Show<string> so a mis-dispatch could resolve to a visible wrong answer, and it still errored rather than returning it. Combined with the shape being unsupported on Lua by design, and the diagnostic having to be target-aware since it compiles fine on Jass, a dedicated pre-lowering rejection looked like more machinery than the message is worth. Left as is.

Reproducing it did expose a real defect in the specializer, fixed in 2ec9571. Resolving a dispatch looked up the type variable's position and then indexed the type arguments without checking that the two lists correspond. With a method declaring its own type parameters alongside its class's there are two variables and one argument, so index 0 read the method's argument for the class's variable, and index 1 would have run off the end. That is the same positional confusion you found in the interpreter last round, still live here. Both lookups now bail when the arities disagree.

So the symptom stays an honest error on an unsupported shape, and the latent mis-index behind it is gone.

Resolving a type class dispatch looked up the type variable's position and then
indexed the type arguments without checking the two lists correspond. A method
which declares its own type parameters alongside its class's is called with only
its own, so position no longer means anything: the class's variable read the
method's argument, and a later variable would have run off the end.

That is the same positional confusion already fixed for the interpreter, still
present in the specializer. Both lookups now bail when the arities disagree, so a
dispatch is left for a pass that has matching arguments rather than resolved
against whichever type happened to sit at that index.

The shape which exposed this, a bounded generic class method with an independent
type parameter, is unsupported on Lua by design and still reports an error there;
it never produced a wrong result, which was verified by making a wrong dispatch
resolvable and confirming it still failed.
@Frotty

Frotty commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

@codex review

Addressed in 2ec9571, though not as reported — the reasoning differs, so it is worth setting out.

The mixed class/method generics shape does fail on Lua as described. It does not produce a wrong result: I verified that specifically by adding an implements Show<string> so a mis-dispatch could resolve to a visible wrong answer, and it still errored. Since the shape is unsupported on Lua by design and compiles fine on Jass, a dedicated rejection would have to be target-aware, which is more machinery than the message is worth. The error stays.

Reproducing it did surface a real defect. Resolving a dispatch looked up a type variable's position and indexed the type arguments without checking the lists correspond. With a method declaring its own type parameters alongside its class's there are two variables and one argument, so the class's variable read the method's argument and a later one would have run off the end — the same positional confusion found in the interpreter last round, still live in the specializer. Both lookups now bail when the arities disagree.

Net: the reported symptom is unchanged and honest, the latent mis-index behind it is fixed.

@Frotty

Frotty commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

Full suite on 2ec9571: 1623 tests, 0 failures.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2ec9571447

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1738 to +1739
private static String typeClassKey(ImType type) {
return type.toString();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Key type-class implementations by structural type identity

When fallback lookup is needed after a type-class binding is lost during generic substitution, two distinct classes with the same simple name in different packages both stringify as that name because ImClassType printing omits the class identity/package. Their registrations therefore collide in putIfAbsent, and the later type can silently dispatch through the first type's implementation. Use structural ImType equality or an identity-preserving key rather than the printed representation.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed and fixed in 47c3049. This one was worth the check: it is a silent wrong dispatch, not an error.

The mechanism is exactly as you describe — ImClass is created with the simple name (JassIm.ImClass(s1, s1.getName(), ...)), so two classes of the same name in different packages produce the same printed key and the second registration is dropped by putIfAbsent.

My first attempt to reproduce it passed, because the per-argument binding survived and the registry was never consulted; the registry is only the fallback for when that binding is lost. Routing the same program through a bounded generic class, which is the case the registry exists for, reproduced it: the second package's value came back with the first package's implementation, no error raised.

Implementations are now held per requirement and matched by structural type equality rather than by printed text. Covered by sameSimpleNameThroughRegistryFallback.

if (!ta.getTypeClassBinding().isEmpty()) {
append(sb, "{");
boolean firstBinding = true;
for (ImTypeClassFunc requirement : ta.getTypeClassBinding().keySet()) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Sort type-class bindings before printing them

When a nested generic type argument carries multiple requirements, its binding is a HashMap, so iterating keySet() gives no stable order. This printed representation feeds both typeClassKey and generic specialization names, allowing identical input to produce different Lua symbols across compiler runs or independently constructed registration/lookup keys to disagree. Emit bindings in a deterministic order.

AGENTS.md reference: AGENTS.md:L277-L280

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 47c3049, and this was my regression: the binding printing was added for debuggability and leaked into keys and specialization names, where a hash map's iteration order has no business being. AGENTS.md section 8 requires identical input to produce byte-identical Lua, so this was a genuine break of an existing guarantee rather than a cosmetic one.

Two changes: the requirement names are sorted before printing, and the instance registry no longer builds keys from the printed form at all, matching on structural type equality instead. So the printing is back to being only for reading, which is what it was meant to be.

The fallback registry keyed implementations on the printed type. A class prints
as its simple name, so two classes of the same name in different packages
collided: the second registration was dropped and its values silently dispatched
through the first's implementation, with no error. Implementations are now held
per requirement and matched by structural type equality.

The printed form also fed those keys and reaches specialization names, while the
binding it prints is a hash map, so identical input could produce different
symbols between runs. The names are sorted, and with the keys no longer built
from text the printing is once again only for reading.
@Frotty

Frotty commented Aug 14, 2026

Copy link
Copy Markdown
Member Author

@codex review

Both needed and both fixed in 47c3049.

Instance selection by printed name was a silent wrong dispatch, not an error, so it was worth confirming rather than reasoning about. ImClass carries the simple name, so two classes of the same name in different packages produced the same key and the second registration was dropped. My first reproduction attempt passed because the per-argument binding survived and the registry, which is only the fallback for when that binding is lost, was never consulted; routing the same program through a bounded generic class reproduced it, with the second package's value returning the first package's result. Implementations are now matched by structural type equality.

Non-deterministic binding printing was my regression: added for debuggability, it leaked into keys and specialization names, which AGENTS.md section 8 requires to be stable for identical input. Names are sorted, and the registry no longer derives keys from printed text at all, so the printing is once again only for reading.

TypeClassTests is now 44, including the cross-package collision as a regression test.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 47c304932c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1196 to +1199
if (index < 0) {
// dispatching on a variable of some enclosing generic; it is resolved when that one is
// specialised.
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Resolve dispatches on class-owned or captured type variables

When a bound dispatch occurs in a generic-class constructor or inside a closure nested in a bounded generic, its type variable is not in the specialized function's typeVars, so this branch returns on the assumption that an enclosing specialization will resolve it. No later pass revisits that node: a valid constructor such as Box<T: Show>.construct(T x) calling T.show(x) reaches the Lua translator as ImTypeVarDispatch and throws Error: not implemented; the closure form also fails in the interpreter with No type argument bound for Q. Carry the owning or captured type argument into the specialized function instead of leaving the dispatch unresolved.

AGENTS.md reference: AGENTS.md:L217-L221

Useful? React with 👍 / 👎.

Comment on lines +101 to +104
for (InterfaceDef bound : TypeClassConstraints.boundInterfaces(def)) {
for (FuncDef method : bound.getMethods()) {
if (method.getName().equals(name)) {
result.add(requirementLink(node, bound, method));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor bound order for duplicate requirement signatures

When two bounds declare the same requirement signature, such as <Q: First and Second> where both interfaces define show(Q), this loop exposes both links and every Q.show(x) call is rejected as ambiguous. TypeClassConstraints.findRequiredMethod explicitly defines earlier bounds as winning, but this member lookup never applies that rule, so an otherwise satisfiable combination of bounds cannot use the shared operation; stop after the first applicable bound or otherwise deduplicate according to source order.

Useful? React with 👍 / 👎.

@Frotty
Frotty merged commit 82a298b into master Aug 14, 2026
6 checks passed
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.

1 participant