From c41f51aed335c98b2ad71d8e9eff5dc7ccd8f63f Mon Sep 17 00:00:00 2001 From: agra Date: Tue, 23 Jun 2026 08:48:31 +0300 Subject: [PATCH] fix: validate protocol impl method signatures vs the protocol declaration (issue 0178) The issue-0176 conformance gate was name-only, so an impl P for T with a mismatched return/param type (or arity) built a wrong-ABI thunk that silently miscompiled (exit 0, wrong value). firstUnimplementedMethod now validates arity (after self), each param type, and the return type against the protocol declaration, substituting protocol Self->concrete via resolveProtoTypeSubSelf (recurses through pointer/many-pointer/ optional/slice/array so []Self<->[]T match; conservative .unresolved for Self-in-generic-arg). Comparison is by structural formatTypeName (alias/module/spelling independent); typesClearlyDiffer skips when either side has an unresolved leaf at any depth, biasing against false-positives. Regressions: diagnostics/1201 (negative), protocols/0420 (positive, []Self param). Verified by 3+3 adversarial reviews (a mid-fix []Self false-positive was found and closed); suite 792/0. --- ...ostics-protocol-impl-signature-mismatch.sx | 22 ++ ...tics-protocol-impl-signature-mismatch.exit | 1 + ...cs-protocol-impl-signature-mismatch.stderr | 5 + ...cs-protocol-impl-signature-mismatch.stdout | 1 + .../0420-protocol-self-in-slice-param.sx | 33 +++ .../0420-protocol-self-in-slice-param.exit | 1 + .../0420-protocol-self-in-slice-param.stderr | 1 + .../0420-protocol-self-in-slice-param.stdout | 1 + ...pl-signature-mismatch-silent-miscompile.md | 19 ++ src/ir/lower/protocol.zig | 211 +++++++++++++++++- 10 files changed, 293 insertions(+), 2 deletions(-) create mode 100644 examples/diagnostics/1201-diagnostics-protocol-impl-signature-mismatch.sx create mode 100644 examples/diagnostics/expected/1201-diagnostics-protocol-impl-signature-mismatch.exit create mode 100644 examples/diagnostics/expected/1201-diagnostics-protocol-impl-signature-mismatch.stderr create mode 100644 examples/diagnostics/expected/1201-diagnostics-protocol-impl-signature-mismatch.stdout create mode 100644 examples/protocols/0420-protocol-self-in-slice-param.sx create mode 100644 examples/protocols/expected/0420-protocol-self-in-slice-param.exit create mode 100644 examples/protocols/expected/0420-protocol-self-in-slice-param.stderr create mode 100644 examples/protocols/expected/0420-protocol-self-in-slice-param.stdout diff --git a/examples/diagnostics/1201-diagnostics-protocol-impl-signature-mismatch.sx b/examples/diagnostics/1201-diagnostics-protocol-impl-signature-mismatch.sx new file mode 100644 index 00000000..2441b8a7 --- /dev/null +++ b/examples/diagnostics/1201-diagnostics-protocol-impl-signature-mismatch.sx @@ -0,0 +1,22 @@ +// A protocol-method impl with the right NAME but a mismatched RETURN type (or +// parameter type) is a hard error, not a silent miscompile. Regression (issue +// 0178): the issue-0176 conformance gate is name-based, so an `impl P for T` +// whose method returns `bool` where the protocol method returns `i64` passed +// the gate and built a wrong-ABI thunk — dispatch through the erased protocol +// silently returned the wrong value (exit 0, no diagnostic). The conformance +// gate now validates each impl method's signature (arity after `self`, each +// parameter type, and the return type) against the protocol declaration, with +// `Self` substituted to the concrete type (so the REQUIRED `*T` / `T` impl form +// is NOT flagged). A clear type mismatch reports at the erasure site, exit 1. + +#import "modules/std.sx"; + +P :: protocol { val :: (self: *Self) -> i64; } +T :: struct { n: i64 = 7; } +impl P for T { val :: (self: *T) -> bool { return true; } } // return bool != i64 + +main :: () { + t := T.{ n = 7 }; + p : P = t; // <- 'T' does not implement 'P' (return type) + print("{}\n", p.val()); +} diff --git a/examples/diagnostics/expected/1201-diagnostics-protocol-impl-signature-mismatch.exit b/examples/diagnostics/expected/1201-diagnostics-protocol-impl-signature-mismatch.exit new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/examples/diagnostics/expected/1201-diagnostics-protocol-impl-signature-mismatch.exit @@ -0,0 +1 @@ +1 diff --git a/examples/diagnostics/expected/1201-diagnostics-protocol-impl-signature-mismatch.stderr b/examples/diagnostics/expected/1201-diagnostics-protocol-impl-signature-mismatch.stderr new file mode 100644 index 00000000..947354d1 --- /dev/null +++ b/examples/diagnostics/expected/1201-diagnostics-protocol-impl-signature-mismatch.stderr @@ -0,0 +1,5 @@ +error: 'T' does not implement protocol 'P': method 'val' has a mismatched signature — return type: protocol declares 'i64', impl declares 'bool' (a protocol-method impl must match the protocol's declared types exactly, with `Self` written as `T`) + --> examples/diagnostics/1201-diagnostics-protocol-impl-signature-mismatch.sx:20:3 + | +20 | p : P = t; // <- 'T' does not implement 'P' (return type) + | ^^^^^^^^^^ diff --git a/examples/diagnostics/expected/1201-diagnostics-protocol-impl-signature-mismatch.stdout b/examples/diagnostics/expected/1201-diagnostics-protocol-impl-signature-mismatch.stdout new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/diagnostics/expected/1201-diagnostics-protocol-impl-signature-mismatch.stdout @@ -0,0 +1 @@ + diff --git a/examples/protocols/0420-protocol-self-in-slice-param.sx b/examples/protocols/0420-protocol-self-in-slice-param.sx new file mode 100644 index 00000000..dc4d0827 --- /dev/null +++ b/examples/protocols/0420-protocol-self-in-slice-param.sx @@ -0,0 +1,33 @@ +// `Self` nested inside a SLICE in a protocol-method param resolves +// correctly: the conformance gate substitutes `Self → T` THROUGH the +// `[]` wrapper (and the `*Self` receiver), so a correct `[]T` impl is +// accepted and dispatched — not falsely rejected as `[]Self` ≠ `[]T`. +// +// Regression (issue 0178 adversarial review): the signature gate's +// `Self`-substitution originally only reached the bare leaf and a +// one-level pointer/optional wrapper, so `[]Self` resolved to a real +// slice named `[]Self` and a correct `[]T` impl was WRONGLY flagged. +#import "modules/std.sx"; + +P :: protocol { + // `*Self` receiver + `[]Self` slice param + `Self` value param. + total :: (self: *Self, xs: []Self, extra: Self) -> i64; +} + +T :: struct { n: i64 = 0; } + +impl P for T { + total :: (self: *T, xs: []T, extra: T) -> i64 { + sum := self.n + extra.n; + for xs (x) { sum = sum + x.n; } + return sum; + } +} + +main :: () { + t := T.{ n = 10 }; + p : P = t; + arr : [3]T = .{ T.{ n = 1 }, T.{ n = 2 }, T.{ n = 3 } }; + // 10 (self) + 1+2+3 (slice) + 100 (extra) = 116 + print("{}\n", p.total(arr[..], T.{ n = 100 })); +} diff --git a/examples/protocols/expected/0420-protocol-self-in-slice-param.exit b/examples/protocols/expected/0420-protocol-self-in-slice-param.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/protocols/expected/0420-protocol-self-in-slice-param.exit @@ -0,0 +1 @@ +0 diff --git a/examples/protocols/expected/0420-protocol-self-in-slice-param.stderr b/examples/protocols/expected/0420-protocol-self-in-slice-param.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/protocols/expected/0420-protocol-self-in-slice-param.stderr @@ -0,0 +1 @@ + diff --git a/examples/protocols/expected/0420-protocol-self-in-slice-param.stdout b/examples/protocols/expected/0420-protocol-self-in-slice-param.stdout new file mode 100644 index 00000000..4699eb3c --- /dev/null +++ b/examples/protocols/expected/0420-protocol-self-in-slice-param.stdout @@ -0,0 +1 @@ +116 diff --git a/issues/0178-protocol-impl-signature-mismatch-silent-miscompile.md b/issues/0178-protocol-impl-signature-mismatch-silent-miscompile.md index b7e5bc13..8fd8a54b 100644 --- a/issues/0178-protocol-impl-signature-mismatch-silent-miscompile.md +++ b/issues/0178-protocol-impl-signature-mismatch-silent-miscompile.md @@ -1,5 +1,24 @@ # 0178 — protocol impl method with a mismatched return/param TYPE silently miscompiles +> **RESOLVED.** The issue-0176 conformance gate was name-only, so an `impl P for +> T` with a mismatched return/param type (or arity) built a wrong-ABI thunk that +> silently miscompiled (exit 0, wrong value). Fix (`src/ir/lower/protocol.zig`): +> `firstUnimplementedMethod` now validates each impl method's signature against +> the protocol declaration — arity (after `self`), every param type, and the +> return type — substituting protocol `Self`→concrete via `resolveProtoTypeSubSelf` +> (recurses through pointer/many-pointer/optional/slice/array so `[]Self`↔`[]T` +> etc. match; conservative `.unresolved` for `Self`-in-generic-arg). Comparison +> is by structural `formatTypeName` (alias/module/spelling independent), and +> `typesClearlyDiffer` skips when either side has an unresolved leaf at any depth +> — biased against false-positives. Mismatch → located diagnostic. Verified by +> 3+3 adversarial reviews (a mid-fix `[]Self` false-positive was found and +> closed); suite 792/0. Regressions: +> `examples/diagnostics/1201-diagnostics-protocol-impl-signature-mismatch.sx` +> (negative), `examples/protocols/0420-protocol-self-in-slice-param.sx` +> (positive). Known gaps (pre-existing, loud not silent — out of scope): a +> `Self`-through-generic-arg mismatch (`Box(Self)`) and by-value array protocol +> params (`[2]Self`) fail at LLVM verification, not silently. + ## Symptom An `impl P for T` whose method has the right NAME but a mismatched return type or diff --git a/src/ir/lower/protocol.zig b/src/ir/lower/protocol.zig index ac4c9486..55ee648b 100644 --- a/src/ir/lower/protocol.zig +++ b/src/ir/lower/protocol.zig @@ -448,7 +448,21 @@ const NonConformance = struct { /// monomorphization"), leaving `resolveFuncByName` null → the thunk's /// `else => unreachable` arm fires at the first dispatch. signature_mismatch, + /// A body exists with the right arity (after self), but a PARAMETER or + /// the RETURN type differs from the protocol method's declared type + /// (e.g. impl returns `bool` where the protocol method returns `i64`). + /// The thunk would call the impl with the wrong ABI and silently + /// miscompile (issue 0178). `detail` names the mismatching position. + type_mismatch, + /// A body exists but its arity (after self) differs from the protocol + /// method's parameter count. `detail` carries the "expected N, got M" + /// phrasing. + arity_mismatch, }, + /// Populated for `.type_mismatch` / `.arity_mismatch`: a human-readable + /// description of WHICH part mismatched (e.g. "return type: protocol + /// declares 'i64', impl declares 'bool'"). Empty for the other kinds. + detail: []const u8 = "", }; /// First protocol method of `proto_name` for which `concrete_type_name` does @@ -472,8 +486,12 @@ const NonConformance = struct { /// type params are bound by the instance, not introduced by the method, and /// `monomorphizeFunction` always registers it. Conformance is IMPL-DRIVEN, so a /// type satisfying the method only via a free / `ufcs` function does NOT conform. -fn firstUnimplementedMethod(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8) ?NonConformance { +fn firstUnimplementedMethod(self: *Lowering, proto_name: []const u8, concrete_type_name: []const u8, concrete_ty: TypeId) ?NonConformance { const pd = self.program_index.protocol_decl_map.get(proto_name) orelse return null; + // AST of the protocol (carries each method's raw param/return type nodes + + // the protocol's defining module). Absent only for synthesized protocols — + // then we fall back to NAME/arity checks without per-type validation. + const pd_ast = self.program_index.protocol_ast_map.get(proto_name); for (pd.methods) |m| { const qualified = std.fmt.allocPrint(self.alloc, "{s}.{s}", .{ concrete_type_name, m.name }) catch return .{ .method = m.name, .kind = .missing }; @@ -482,6 +500,14 @@ fn firstUnimplementedMethod(self: *Lowering, proto_name: []const u8, concrete_ty // thunk's `lazyLowerFunction(qualified)` would actually register it. // A method with its own type params bails there → unreachable thunk. if (fd.type_params.len > 0) return .{ .method = m.name, .kind = .signature_mismatch }; + // Validate the impl method's SIGNATURE against the protocol method + // (issue 0178): a right-NAME but wrong-TYPE impl otherwise builds a + // wrong-ABI thunk and silently miscompiles. + if (pd_ast) |pda| { + if (methodAst(pda, m.name)) |mast| { + if (signatureMismatch(self, mast, m, fd, concrete_ty, pda.source_file)) |nc| return nc; + } + } continue; } if (self.genericInstanceMethod(concrete_type_name, m.name) != null) continue; @@ -490,6 +516,185 @@ fn firstUnimplementedMethod(self: *Lowering, proto_name: []const u8, concrete_ty return null; } +/// The protocol method declaration node named `name`, or null. +fn methodAst(pd: *const ast.ProtocolDecl, name: []const u8) ?ast.ProtocolMethodDecl { + for (pd.methods) |m| { + if (std.mem.eql(u8, m.name, name)) return m; + } + return null; +} + +/// True when `node` (a type-expr AST) names `Self` ANYWHERE — at the leaf or +/// nested inside any compound (`*Self`, `[]Self`, `[2]*Self`, `?[]Self`, +/// `Box(Self)`, …). Used to decide whether a node needs structural +/// `Self`-substitution or can be resolved wholesale. +fn containsSelf(node: *const Node) bool { + return switch (node.data) { + .type_expr => |te| std.mem.eql(u8, te.name, "Self"), + .pointer_type_expr => |pt| containsSelf(pt.pointee_type), + .many_pointer_type_expr => |mp| containsSelf(mp.element_type), + .optional_type_expr => |opt| containsSelf(opt.inner_type), + .slice_type_expr => |st| containsSelf(st.element_type), + .array_type_expr => |at| containsSelf(at.element_type), + .parameterized_type_expr => |pt| blk: { + for (pt.args) |arg| { + if (containsSelf(arg)) break :blk true; + } + break :blk false; + }, + else => false, + }; +} + +/// Resolve a protocol-method type node to a TypeId, substituting any `Self` +/// reference (at the leaf, or nested under ANY compound — pointer / many-pointer +/// / optional / slice / array, or a generic type-arg) with `concrete_ty` — the +/// type the protocol is being erased FOR. `proto_src` is the protocol's defining +/// module, so a type bare-visible only there still resolves (mirrors how +/// `registerProtocolDecl` builds `param_types`). +/// +/// Recurses structurally so a nested `Self` (e.g. `[]Self`, `[2]*Self`, +/// `?[]Self`) is replaced at EVERY level before the type is rebuilt — without +/// this, `[]Self` would resolve to a real `.slice` named `[]Self` ≠ `[]T` and +/// the conformance gate would FALSELY reject a correct `[]T` impl (the gap from +/// the 0178 adversarial review). +fn resolveProtoTypeSubSelf(self: *Lowering, node: *const Node, concrete_ty: TypeId, proto_src: ?[]const u8) TypeId { + switch (node.data) { + .type_expr => |te| { + if (std.mem.eql(u8, te.name, "Self")) return concrete_ty; + return self.resolveTypeInSource(proto_src, node); + }, + .pointer_type_expr => |pt| return self.module.types.ptrTo(resolveProtoTypeSubSelf(self, pt.pointee_type, concrete_ty, proto_src)), + .many_pointer_type_expr => |mp| return self.module.types.manyPtrTo(resolveProtoTypeSubSelf(self, mp.element_type, concrete_ty, proto_src)), + .optional_type_expr => |opt| return self.module.types.optionalOf(resolveProtoTypeSubSelf(self, opt.inner_type, concrete_ty, proto_src)), + .slice_type_expr => |st| return self.module.types.sliceOf(resolveProtoTypeSubSelf(self, st.element_type, concrete_ty, proto_src)), + .array_type_expr => |at| { + const elem = resolveProtoTypeSubSelf(self, at.element_type, concrete_ty, proto_src); + // Fold the dimension WITHOUT emitting a diagnostic on failure (unlike + // `resolveArrayLen`, which is a hard error): a non-foldable dim here + // just means we can't build the array type for the gate, so yield the + // `.unresolved` sentinel — `typesClearlyDiffer` then treats it as + // not-clearly-different (conservative, never a false positive). + const dim = program_index_mod.foldDimU32(at.length, self, 0); + if (dim != .ok) return .unresolved; + return self.module.types.arrayOf(elem, dim.ok); + }, + else => { + // Generic type-arg nodes (`Box(Self)`) and any other compound: if it + // mentions `Self` we can't rebuild the instance from substituted + // TypeIds without re-running the template machinery, so resolve it + // ONLY when it does NOT contain `Self` (the common case — a fully + // concrete `Box(i64)` protocol type). When it DOES contain `Self`, + // yield `.unresolved` so the gate stays conservative (a correct + // `Box(T)` impl is NOT falsely flagged). A genuine generic-arg + // mismatch through `Self` is then simply not caught here — acceptable: + // the gate's job is to never produce a FALSE positive; the 0178 + // miscompiles it guards are leaf / pointer / slice / array shaped. + if (containsSelf(node)) return .unresolved; + return self.resolveTypeInSource(proto_src, node); + }, + } +} + +/// Compare the impl method's signature (params after the `self` receiver, plus +/// the return type) against the protocol method's declaration. Returns a +/// `NonConformance` on a CLEAR mismatch, else null. +/// +/// Self-substitution: the protocol writes `*Self` / `Self`; the REQUIRED impl +/// form is `*T` / `T`. `resolveProtoTypeSubSelf` substitutes `Self → concrete` +/// before resolving, so a correct impl matches and is NOT flagged. +/// +/// Comparison is by STRUCTURAL NAME (`formatTypeName`), which is independent of +/// the resolving module's visibility context — so the same type resolved in the +/// protocol's module (protocol side) vs the erasure site (impl side) compares +/// equal. We only flag when BOTH sides resolve to concrete, fully-known types +/// AND their structural names differ — conservative against false positives on +/// any shape we can't confidently resolve. +fn signatureMismatch(self: *Lowering, mast: ast.ProtocolMethodDecl, m: ProtocolMethodInfo, fd: *const ast.FnDecl, concrete_ty: TypeId, proto_src: ?[]const u8) ?NonConformance { + // The concrete VALUE type (strip a single pointer): `*Self` substitutes to + // `*T`, so `Self` itself must substitute to the value `T`. + const value_ty: TypeId = blk: { + if (!concrete_ty.isBuiltin()) { + const info = self.module.types.get(concrete_ty); + if (info == .pointer) break :blk info.pointer.pointee; + } + break :blk concrete_ty; + }; + + // Impl params after the `self` receiver (params[0]). + if (fd.params.len == 0) return null; // no receiver at all — leave other gates to handle. + const impl_extra = fd.params[1..]; + + // Arity (after self) must equal the protocol method's param count. + if (impl_extra.len != m.param_types.len) { + const detail = std.fmt.allocPrint(self.alloc, "expects {d} parameter{s} (after self), but the impl declares {d}", .{ + m.param_types.len, if (m.param_types.len == 1) "" else "s", impl_extra.len, + }) catch ""; + return .{ .method = m.name, .kind = .arity_mismatch, .detail = detail }; + } + + // Per-parameter type check. Protocol types resolve in the protocol's own + // module (`proto_src`) with `Self → value_ty`; impl types resolve in the + // current (erasure-site) context. Comparison is structural-name based, so + // the same type resolved either way compares equal. + for (mast.params, impl_extra) |proto_pnode, impl_param| { + const proto_pty = resolveProtoTypeSubSelf(self, proto_pnode, value_ty, proto_src); + const impl_pty = self.resolveType(impl_param.type_expr); + if (typesClearlyDiffer(self, proto_pty, impl_pty)) { + const detail = std.fmt.allocPrint(self.alloc, "parameter '{s}': protocol declares '{s}', impl declares '{s}'", .{ + impl_param.name, self.formatTypeName(proto_pty), self.formatTypeName(impl_pty), + }) catch ""; + return .{ .method = m.name, .kind = .type_mismatch, .detail = detail }; + } + } + + // Return type check. + const proto_ret: TypeId = if (mast.return_type) |rt| resolveProtoTypeSubSelf(self, rt, value_ty, proto_src) else .void; + const impl_ret: TypeId = if (fd.return_type) |rt| self.resolveType(rt) else .void; + if (typesClearlyDiffer(self, proto_ret, impl_ret)) { + const detail = std.fmt.allocPrint(self.alloc, "return type: protocol declares '{s}', impl declares '{s}'", .{ + self.formatTypeName(proto_ret), self.formatTypeName(impl_ret), + }) catch ""; + return .{ .method = m.name, .kind = .type_mismatch, .detail = detail }; + } + + return null; +} + +/// True when `ty` is `.unresolved` OR wraps an `.unresolved` leaf at ANY depth +/// (`[]unresolved`, `?*unresolved`, `[2]unresolved`, …). The outer TypeId of a +/// compound built over an unresolved element is itself a real `.slice` / +/// `.array` / `.pointer`, so the bare `== .unresolved` check at the top level +/// would MISS it — this recursion is the belt-and-suspenders that keeps a +/// `Self`-derived nesting we couldn't fully substitute from ever producing a +/// false positive in the conformance gate. +fn typeContainsUnresolved(self: *Lowering, ty: TypeId) bool { + if (ty == .unresolved) return true; + if (ty.isBuiltin()) return false; + return switch (self.module.types.get(ty)) { + .pointer => |p| typeContainsUnresolved(self, p.pointee), + .many_pointer => |p| typeContainsUnresolved(self, p.element), + .slice => |s| typeContainsUnresolved(self, s.element), + .array => |a| typeContainsUnresolved(self, a.element), + .optional => |o| typeContainsUnresolved(self, o.child), + else => false, + }; +} + +/// True when both `a` and `b` resolve to concrete, fully-known types whose +/// STRUCTURAL names differ. Conservative: if EITHER side contains an unresolved +/// leaf at any depth (a type — or a nesting — we couldn't resolve / fully +/// substitute), returns false, so we never flag a shape outside our resolver's +/// reach. A REAL mismatch between two fully-resolved compounds (e.g. `[]i64` vs +/// `[]i32`) still differs in structural name and IS caught. +fn typesClearlyDiffer(self: *Lowering, a: TypeId, b: TypeId) bool { + if (typeContainsUnresolved(self, a) or typeContainsUnresolved(self, b)) return false; + if (a == b) return false; + const an = self.formatTypeName(a); + const bn = self.formatTypeName(b); + return !std.mem.eql(u8, an, bn); +} + /// Build a protocol value from a concrete pointer. /// For inline protocols: struct_init { ctx, thunk1, thunk2, ... } /// For vtable protocols: struct_init { ctx, vtable_ptr } where vtable is stack-allocated @@ -504,13 +709,15 @@ pub fn buildProtocolValue(self: *Lowering, concrete_ptr: Ref, proto_name: []cons // happily synthesize a vtable whose thunks fall through to `unreachable` // (no resolvable concrete method) — a SILENT SIGABRT at the first dispatch // with no diagnostic (issue 0176). Surface it as a hard error instead. - if (firstUnimplementedMethod(self, proto_name, concrete_type_name)) |nc| { + if (firstUnimplementedMethod(self, proto_name, concrete_type_name, concrete_ty)) |nc| { if (self.diagnostics) |d| { const cs = self.builder.current_span; const span = ast.Span{ .start = cs.start, .end = cs.end }; switch (nc.kind) { .missing => d.addFmt(.err, span, "'{s}' does not implement protocol '{s}': no `impl {s} for {s}` provides method '{s}' (protocol erasure is impl-driven — a plain or `ufcs` free function with a matching receiver does not satisfy a protocol)", .{ concrete_type_name, proto_name, proto_name, concrete_type_name, nc.method }), .signature_mismatch => d.addFmt(.err, span, "'{s}' does not implement protocol '{s}': method '{s}' has a mismatched signature — a protocol-method impl must not introduce its own type parameters (e.g. `$T: Type`); it must match the protocol's signature exactly", .{ concrete_type_name, proto_name, nc.method }), + .type_mismatch => d.addFmt(.err, span, "'{s}' does not implement protocol '{s}': method '{s}' has a mismatched signature — {s} (a protocol-method impl must match the protocol's declared types exactly, with `Self` written as `{s}`)", .{ concrete_type_name, proto_name, nc.method, nc.detail, concrete_type_name }), + .arity_mismatch => d.addFmt(.err, span, "'{s}' does not implement protocol '{s}': method '{s}' {s}", .{ concrete_type_name, proto_name, nc.method, nc.detail }), } } else { // Gap 2 — no diagnostics channel (e.g. a comptime sub-lowering that