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 } };
}