fix(resolver): share plan SelectedFunc across consumers + route UFCS through selector [stdlib C attempt-2]

Address Phase C review (C-1, C-2): make CallResolver.plan's SelectedFunc the
single shared call author consumed by the lower-call sites instead of each
re-resolving; route free-fn value-receiver UFCS through the selector in plan so
plan typing and lowering pick the same author under a flat same-name collision.
Adds regression 0740-modules-flat-same-name-ufcs-typing.

Salvaged from a worker killed at the wall during its final gate step; manager
verified the gate at ground truth (zig build test exit 0; run_examples 476/0 with
0722-0735 + 0740 ok; m3te ios-sim exit 0).
This commit is contained in:
agra
2026-06-07 12:05:12 +03:00
parent 9568f7689f
commit 2dd6c3c13b
8 changed files with 200 additions and 125 deletions

View File

@@ -157,26 +157,19 @@ pub const CallResolver = struct {
if (std.mem.eql(u8, bare_name, "type_of")) return refl(bare_name, .any);
if (std.mem.eql(u8, bare_name, "field_value")) return refl(bare_name, .any);
// Plain bare same-name flat collision (R5 §C): route through the ONE
// selector so `plan` reads the SAME author the lowering call-path
// binds — they can no longer disagree (fix-0102 F2). The gate mirrors
// `lowerCall`'s: a plain top-level identifier with no scope-mangle /
// local shadow. A generic / foreign / builtin author is not plain-free
// so the selector returns `.none`; `.ambiguous` / `.none` fall through
// to the first-wins path below, byte-for-byte.
if (std.mem.eql(u8, name, bare_name) and
(if (self.l.scope) |scope| scope.lookup(bare_name) == null else true))
{
if (self.l.current_source_file) |caller_file| {
switch (self.l.selectPlainCallableAuthor(bare_name, caller_file)) {
.func => |sf| return .{
.kind = .direct_fn,
.return_type = if (sf.decl.return_type) |rt| self.l.resolveType(rt) else .void,
.target = .{ .selected = sf },
.expands_defaults = defaultsFor(sf.decl, c.args.len),
},
.ambiguous, .none => {},
}
}
// author producer `selectedFreeAuthor` so `plan` types the call as the
// SAME author the lowering call-path binds — they can no longer
// disagree (fix-0102 F2). A generic / foreign / builtin author is not
// plain-free so the producer returns `.none`; `.ambiguous` / `.none`
// fall through to the first-wins path below, byte-for-byte.
switch (self.selectedFreeAuthor(c)) {
.func => |sf| return .{
.kind = .direct_fn,
.return_type = if (sf.decl.return_type) |rt| self.l.resolveType(rt) else .void,
.target = .{ .selected = sf },
.expands_defaults = defaultsFor(sf.decl, c.args.len),
},
.ambiguous, .none => {},
}
// Generic function — infer return type via type bindings.
if (self.l.program_index.fn_ast_map.get(name)) |fd| {
@@ -322,6 +315,26 @@ pub const CallResolver = struct {
// the plan carries `prepends_receiver`, distinct from a true
// namespace call (`pkg.fn()`), which must NOT prepend.
if (self.objectIsValue(cfa.object)) {
// Value-receiver free-fn UFCS (`recv.fn(args)` → `fn(recv, args)`)
// routes through the SAME author producer `selectedFreeAuthor` as a
// bare call, so the planned target / return type IS the author
// lowering dispatches — they can't disagree under a flat same-name
// collision (fix-0102 F2 / R5 §C). Without this, plan typed the
// first-wins winner while lowering bound the selected shadow,
// mis-tagging the call's result (a string-typed winner over an s64
// shadow boxes a raw int as a string pointer → segfault).
// `.ambiguous` / `.none` fall through to the first-wins path below,
// unchanged.
switch (self.selectedFreeAuthor(c)) {
.func => |sf| return .{
.kind = .free_fn_ufcs,
.return_type = if (sf.decl.return_type) |rt| self.l.resolveType(rt) else .void,
.target = .{ .selected = sf },
.prepends_receiver = true,
.expands_defaults = defaultsFor(sf.decl, c.args.len + 1),
},
.ambiguous, .none => {},
}
if (self.l.resolveFuncByName(cfa.field)) |fid| {
const func = &self.l.module.functions.items[@intFromEnum(fid)];
return .{
@@ -434,6 +447,47 @@ pub const CallResolver = struct {
return .{ .kind = .unresolved, .return_type = .unresolved };
}
/// THE single producer of the bare / value-UFCS same-name call author
/// verdict (R5 §#3). Both `plan` (typing, via its `.selected` arm) and
/// `lowerCall` (default expansion / param typing / dispatch) consume THIS one
/// result, so they can never pick different same-name authors for the same
/// call (fix-0102 F2). Side-effect-free: it consults ONLY the author selector
/// (`selectPlainCallableAuthor`) — never return-type inference or type-arg
/// resolution — so `lowerCall` can compute it eagerly without emitting a
/// premature diagnostic the full `plan` would (e.g. `cast(type)`'s type-arg).
///
/// - identifier callee: a plain bare call. The gate mirrors `plan`/`lowerCall`
/// — a builtin, a scope-mangled / UFCS-aliased name, or a locally-shadowed
/// name is never a same-name free-fn collision → `.none`.
/// - field-access callee with a VALUE receiver: a free-function UFCS
/// (`recv.fn(args)`). A namespace / type prefix receiver → `.none`. The
/// verdict over-selects a struct-method / protocol / foreign call whose
/// field happens to name a free fn, but those dispatch BEFORE the free-fn
/// UFCS path in both `plan` and `lowerCall`, so the verdict is consumed only
/// when the call truly is a free-fn UFCS.
pub fn selectedFreeAuthor(self: CallResolver, c: *const ast.Call) Lowering.BareCallee {
const caller_file = self.l.current_source_file orelse return .none;
switch (c.callee.data) {
.identifier => |id| {
const bare_name = id.name;
if (Lowering.resolveBuiltin(bare_name) != null) return .none;
const scoped = if (self.l.scope) |scope| scope.lookupFn(bare_name) orelse bare_name else bare_name;
const name = if (self.l.program_index.ufcs_alias_map.get(bare_name)) |target|
(if (self.l.scope) |scope| scope.lookupFn(target) orelse target else target)
else
scoped;
if (!std.mem.eql(u8, name, bare_name)) return .none;
if (self.l.scope) |scope| if (scope.lookup(bare_name) != null) return .none;
return self.l.selectPlainCallableAuthor(bare_name, caller_file);
},
.field_access => |cfa| {
if (!self.objectIsValue(cfa.object)) return .none;
return self.l.selectPlainCallableAuthor(cfa.field, caller_file);
},
else => return .none,
}
}
fn refl(name: []const u8, rt: TypeId) CallPlan {
return .{ .kind = .reflection, .return_type = rt, .target = .{ .named = name } };
}

View File

@@ -7361,10 +7361,27 @@ pub const Lowering = struct {
c = rewritten;
};
}
// fix-0102 F2 / R5 §C: select the bare / value-UFCS same-name call author
// ONCE, via `CallResolver.selectedFreeAuthor` — the SINGLE producer of
// this verdict, the exact same one `CallResolver.plan` consumes for typing.
// The call-path consumers (default expansion, param typing, dispatch) all
// read THIS one author object, so plan-typing and lowering-dispatch can no
// longer disagree about which same-name function the call names, and the
// shadow's FuncId is materialized at most once (into `author_verdict`).
// `selectedFreeAuthor` is side-effect-free (it only runs the author
// selector — no return-type inference / type-arg resolution), so computing
// it eagerly here can't emit a premature diagnostic the way the full plan
// would.
var author_verdict = self.callResolver().selectedFreeAuthor(c);
const sel_author: ?*SelectedFunc = switch (author_verdict) {
.func => |*sf| sf,
else => null,
};
const author_ambiguous = author_verdict == .ambiguous;
// Expand default parameter values for bare identifier callees:
// when the caller omits trailing positional args, fill them in
// from the callee's `param: T = expr` declarations.
if (self.expandCallDefaults(c)) |expanded| c = expanded;
if (self.expandCallDefaults(c, sel_author, author_ambiguous)) |expanded| c = expanded;
// Check reflection builtins first (before lowering args — some args are type names, not values)
if (c.callee.data == .identifier) {
if (self.tryLowerReflectionCall(c.callee.data.identifier.name, c)) |ref| return ref;
@@ -7515,7 +7532,7 @@ pub const Lowering = struct {
var args = std.ArrayList(Ref).empty;
defer args.deinit(self.alloc);
// Try to resolve param types for target_type context
const param_types = self.resolveCallParamTypes(c);
const param_types = self.resolveCallParamTypes(c, sel_author);
// For enum_literal callees (.Variant(payload)), resolve the payload target type
// from the union field type so struct literal fields get proper coercion
var enum_payload_ty: ?TypeId = null;
@@ -7728,41 +7745,33 @@ pub const Lowering = struct {
}
}
}
// fix-0102c: a genuine flat same-name collision — bind the
// caller file's OWN author (or its single flat-reachable
// author), or reject a bare call to a name ≥2 imported modules
// author. Only a plain top-level identifier call routes here:
// scope-mangled / UFCS-aliased / locally-shadowed names and
// 0/1-author names fall straight to the existing path below
// (`selectPlainCallableAuthor` returns `.none`).
if (std.mem.eql(u8, func_name, id.name) and
(if (self.scope) |scope| scope.lookup(id.name) == null else true))
{
if (self.current_source_file) |caller_file| {
switch (self.selectPlainCallableAuthor(func_name, caller_file)) {
.none => {},
.ambiguous => {
if (self.diagnostics) |d|
d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{func_name});
return Ref.none;
},
.func => |sf| {
var selected = sf;
const fid = self.selectedFuncId(&selected, func_name);
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
// The RESOLVED author's decl drives variadic
// packing — not a first-wins re-lookup by name,
// whose variadic shape may differ (fix-0102c F1).
self.packVariadicCallArgs(selected.decl, c, &args);
const final_args = self.prependCtxIfNeeded(func, args.items);
self.coerceCallArgs(final_args, params);
if (func.is_variadic) self.promoteCVariadicArgs(final_args, params.len);
return self.builder.call(fid, final_args, ret_ty);
},
}
}
// fix-0102c / R5 §C: a genuine flat same-name collision — bind the
// author the call resolver selected (own-author-wins, or the single
// flat-reachable author), or reject a bare call to a name ≥2
// imported modules author. `selectedFreeAuthor` (computed once
// above, and the exact verdict `plan` consumes for typing) is the
// single producer; lowering CONSUMES it rather than re-resolving
// the name, so typing and dispatch read the SAME author and can't
// disagree (fix-0102 F2). Reached only for an identifier callee, so
// `sel_author` / `author_ambiguous` here are the bare verdict.
if (author_ambiguous) {
if (self.diagnostics) |d|
d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{func_name});
return Ref.none;
}
if (sel_author) |sf| {
const fid = self.selectedFuncId(sf, func_name);
const func = &self.module.functions.items[@intFromEnum(fid)];
const ret_ty = func.ret;
const params = func.params;
// The RESOLVED author's decl drives variadic packing — not a
// first-wins re-lookup by name, whose variadic shape may
// differ (fix-0102c F1).
self.packVariadicCallArgs(sf.decl, c, &args);
const final_args = self.prependCtxIfNeeded(func, args.items);
self.coerceCallArgs(final_args, params);
if (func.is_variadic) self.promoteCVariadicArgs(final_args, params.len);
return self.builder.call(fid, final_args, ret_ty);
}
// Check for comptime-expanded or generic functions
if (self.program_index.fn_ast_map.get(func_name)) |fd| {
@@ -8247,26 +8256,24 @@ pub const Lowering = struct {
// a function reached ONLY via UFCS would otherwise be declared
// but never emitted (issue 0063: undefined symbol at link).
//
// fix-0102d site 3: a free-function UFCS target with a genuine
// flat same-name collision must dispatch to the RESOLVED author
// for the receiver's source, not the first-wins winner. The
// field name is never scope-mangled, so the only gate is a
// known source file; `.ambiguous` → loud diagnostic; `.none`
// → existing first-wins path.
// fix-0102d site 3 / R5 §C: a free-function UFCS target with a
// genuine flat same-name collision dispatches to the author the
// call PLAN selected for the receiver's source — the SAME author
// plan typed the call's result as, so dispatch and typing can't
// disagree (fix-0102 F2; without this, a string-typed winner over
// an s64 shadow boxes a raw int as a string pointer → segfault).
// The plan is the single producer; lowering consumes its verdict
// (`sel_author` / `cplan.ambiguous_collision`, computed once above)
// rather than re-resolving the field name. `.ambiguous` → loud
// diagnostic; otherwise the existing first-wins lazy path.
const ufcs_fid: ?FuncId = blk_uf: {
if (self.current_source_file) |caller_file| {
switch (self.selectPlainCallableAuthor(fa.field, caller_file)) {
.func => |sf| {
var selected = sf;
break :blk_uf self.selectedFuncId(&selected, fa.field);
},
.ambiguous => {
if (self.diagnostics) |d|
d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{fa.field});
return Ref.none;
},
.none => {},
}
if (author_ambiguous) {
if (self.diagnostics) |d|
d.addFmt(.err, c.callee.span, "'{s}' is ambiguous; declared by multiple imported modules — qualify the call", .{fa.field});
return Ref.none;
}
if (sel_author) |sf| {
break :blk_uf self.selectedFuncId(sf, fa.field);
}
if (self.program_index.fn_ast_map.get(fa.field)) |_| {
if (!self.lowered_functions.contains(fa.field)) {
@@ -12088,7 +12095,7 @@ pub const Lowering = struct {
/// callee's signature provides defaults for them, return a fresh Call
/// node with the defaults filled in. Returns null when no expansion is
/// needed (callee unknown, all args provided, or no defaults available).
fn expandCallDefaults(self: *Lowering, c: *const ast.Call) ?*ast.Call {
fn expandCallDefaults(self: *Lowering, c: *const ast.Call, sel_author: ?*const SelectedFunc, author_ambiguous: bool) ?*ast.Call {
const fd = blk: {
switch (c.callee.data) {
.identifier => |id| {
@@ -12099,30 +12106,19 @@ pub const Lowering = struct {
}
break :blk2 scoped;
};
// fix-0102d site 1: for a genuine flat same-name collision the
// omitted trailing args must be filled from the RESOLVED
// author's defaults, not the first-wins winner's. Only a plain
// top-level identifier with no scope-mangle / UFCS alias /
// local shadow routes here; `.ambiguous` declines to expand
// (the call path emits the single diagnostic); `.none` keeps
// the existing first-wins winner, byte-for-byte.
if (std.mem.eql(u8, eff_name, id.name) and
(if (self.scope) |scope| scope.lookup(id.name) == null else true))
{
if (self.current_source_file) |caller_file| {
switch (self.selectPlainCallableAuthor(id.name, caller_file)) {
// Default expansion needs only the author's decl
// (its param defaults) — never the FuncId. Reading
// `sf.decl` here keeps `materialized` null, so a
// bare call whose body is never emitted (e.g. only
// its defaults are inspected) does not lower the
// author (0102d).
.func => |sf| break :blk sf.decl,
.ambiguous => return null,
.none => {},
}
}
}
// fix-0102d site 1 / R5 §C: for a genuine flat same-name
// collision the omitted trailing args are filled from the
// author the call resolver selected — its `*FnDecl` defaults —
// not the first-wins winner's. lowering consumes the ONE author
// verdict (`selectedFreeAuthor`, computed once in `lowerCall`)
// rather than re-resolving the name, so default expansion and
// dispatch agree on the author. `.ambiguous` declines to expand
// (the call path emits the single diagnostic); a non-collision
// call keeps the existing first-wins winner, byte-for-byte.
// Reading `.decl` only keeps `materialized` null — inspecting
// defaults must not lower the author (0102d).
if (author_ambiguous) return null;
if (sel_author) |sf| break :blk sf.decl;
break :blk self.program_index.fn_ast_map.get(eff_name) orelse return null;
},
// Namespace call `mod.fn(args)` — args map directly to params
@@ -12192,7 +12188,7 @@ pub const Lowering = struct {
return types_list.items;
}
fn resolveCallParamTypes(self: *Lowering, c: *const ast.Call) []const TypeId {
fn resolveCallParamTypes(self: *Lowering, c: *const ast.Call, sel_author: ?*SelectedFunc) []const TypeId {
// Method calls: obj.method(args) — resolve param types from the method signature,
// skipping the first param (self) since it's prepended later.
if (c.callee.data == .field_access) {
@@ -12345,29 +12341,20 @@ pub const Lowering = struct {
break :blk scoped;
};
// fix-0102c F2: a genuine flat same-name collision must type this
// call's args against the RESOLVED author's params, not the first-wins
// winner's. Mirror the `lowerCall` routing one layer earlier so arg
// lowering (implicit address-of, coercion) matches the author actually
// called — otherwise a `*T`-param shadow gets a `T` value arg that is
// later bit-cast to a pointer (segfault). Only a plain top-level
// identifier with no scope-mangle / UFCS alias / local shadow routes
// here; `.ambiguous` / `.none` fall to the existing first-wins path so
// single-author / local / std resolution is byte-for-byte unchanged.
if (std.mem.eql(u8, name, bare_name) and
(if (self.scope) |scope| scope.lookup(bare_name) == null else true))
{
if (self.current_source_file) |caller_file| {
switch (self.selectPlainCallableAuthor(bare_name, caller_file)) {
.func => |sf| {
var selected = sf;
const fid = self.selectedFuncId(&selected, bare_name);
const func = &self.module.functions.items[@intFromEnum(fid)];
return self.userParamTypes(func);
},
.ambiguous, .none => {},
}
}
// fix-0102c F2 / R5 §C: a genuine flat same-name collision must type this
// call's args against the author the call resolver selected, not the
// first-wins winner's params. lowering consumes the ONE author verdict
// (`selectedFreeAuthor`, computed once in `lowerCall`) rather than
// re-resolving the name, so arg lowering (implicit address-of, coercion)
// matches the author actually dispatched — otherwise a `*T`-param shadow
// gets a `T` value arg that is later bit-cast to a pointer (segfault). The
// FuncId materializes into the SHARED verdict (once), so dispatch reuses
// it. A non-collision call falls to the existing first-wins path below,
// byte-for-byte.
if (sel_author) |sf| {
const fid = self.selectedFuncId(sf, bare_name);
const func = &self.module.functions.items[@intFromEnum(fid)];
return self.userParamTypes(func);
}
// Check declared functions