feat(stdlib/S2.2): selection verdicts in the resolver [additive]

Move the author-SELECTION SEMANTICS (the verdicts) into the resolver. Every
`.authors` ResolvedRef now carries the verdict the resolver COMPUTES above the
collector — own-wins / single-flat-visible / ≥2-ambiguous / not-visible /
type-vs-value domain-filtered — evaluated over the DOMAIN-ELIGIBLE subset of the
collected author set (`eligibleKind`). This folds the per-kind selection the old
lower-side selectors carried (selectNominalLeaf / flatTypeAuthorCount /
selectModuleConst / selectPlainCallableAuthor / selectGenericStructHead /
headTypeGate / headFnLeak) into ONE uniform computation, closing the
protocol / error-set / foreign per-kind surfaces (E6c/d/e) as resolver behavior.

Template/pack grammar stays carried as `.template` / `.pack` refs — NO
`sig_registration_mode`.

ADDITIVE / PARALLEL / UNCONSUMED: lowering still reads the old selectors, so the
verdict changes no generated byte. No file outside resolver.zig reads
ResolvedRef, so byte-identity is structural. ResolvedRef.authors is wrapped into
{ set, verdict } (the RAW set is preserved; the verdict filters).

Resolver unit tests prove the verdicts on real Phase A facts: the five bare-type
outcomes incl. the type-vs-value filter, and the resolver-target classes the old
selectors get WRONG — 0811 error-set / 0821 protocol-head / 0829 generic-struct
all → ambiguous (two flat authors, none own) and → own-wins (own author present).
The resolver-target corpus stays xfail in run_examples (unconsumed until S3.9);
verdicts asserted via the harness, not by flipping goldens.

Gate: zig build && zig build test (430) && tests/run_examples.sh (540 byte-identical),
all exit 0; tests/resolver-target 18 xfail unchanged.
This commit is contained in:
agra
2026-06-09 15:29:17 +03:00
parent 42acf93401
commit a8d57521ac
2 changed files with 493 additions and 77 deletions

View File

@@ -192,7 +192,19 @@ fn containsAuthor(list: []const RawAuthor, b: RawAuthor) bool {
// is routed to `foreign_class_refs`; a `Type.CONST` field access whose base author
// is a struct carrying that const member fills `struct_const_refs`; and a UFCS
// alias (`alias :: ufcs target`) plus its rewrite call sites fill `ufcs_refs`. All
// ten domains are now populated — still PARALLEL / UNCONSUMED / RAW.
// ten domains are now populated — still PARALLEL / UNCONSUMED.
//
// S2.2 adds the VERDICT layer: every `.authors` ref now carries the selection
// outcome the resolver COMPUTES above the collector — own-wins / single-flat-visible
// / ≥2-ambiguous / not-visible / type-vs-value domain-filtered — evaluated over the
// DOMAIN-ELIGIBLE subset of the author set (`eligibleKind`). This folds the per-kind
// selection semantics the old lower-side selectors carried (`selectNominalLeaf` /
// `flatTypeAuthorCount` / `selectModuleConst` / `selectPlainCallableAuthor` /
// `selectGenericStructHead` / `headTypeGate` / `headFnLeak`) into ONE uniform
// computation, closing the protocol / error-set / foreign per-kind surfaces as
// resolver behavior. The template/pack grammar is the `.template` / `.pack` refs
// above — NO `sig_registration_mode`. STILL ADDITIVE / PARALLEL / UNCONSUMED:
// lowering reads the old selectors, so the verdict changes no generated byte.
/// A symbolic id for one enclosing generic TYPE/VALUE param (`$T`, `$N`), assigned
/// by the pass and indexing `ResolvedProgram.template_params`. Process-local.
@@ -229,12 +241,45 @@ pub const PackRef = struct {
index: ?u32 = null,
};
/// What ONE reference site resolves to — the S2.1 RAW form. `authors` carries the
/// collected author identity (own flat, diamond-deduped) with NO verdict:
/// own-wins / direct-flat ambiguity selection is S2.2. `template` / `pack` are
/// symbolic generic-param references.
/// The selection verdict computed (S2.2) above a reference's collected author set
/// — the own-wins / single-flat-visible / ≥2-ambiguous layer the S0 ledger places
/// "above the collectors, producing ResolvedRef". Evaluated over the DOMAIN-ELIGIBLE
/// subset of the author set (so a same-name VALUE never decides a TYPE reference
/// the type-vs-value `domain_filtered` outcome). ADDITIVE / PARALLEL / UNCONSUMED:
/// lowering still reads the old selectors, so producing these changes no byte.
pub const Verdict = enum {
/// The querying module's OWN author is eligible — it wins outright, regardless
/// of how many same-name flat authors exist.
own_wins,
/// Exactly ONE eligible flat-visible author, no own — the byte-identical
/// single-author path.
single,
/// ≥2 distinct eligible flat-visible authors, no own — a genuine collision the
/// source cannot disambiguate (the LOUD diagnostic at lowering / S3).
ambiguous,
/// No eligible author is flat-visible, but the name IS authored for this domain
/// in some module — reachable only over a namespace edge ⇒ a not-visible leak.
not_visible,
/// Visible same-name author(s) exist but NONE is eligible for this domain (a
/// same-name VALUE for a TYPE reference, etc.) and the name is authored for this
/// domain nowhere — the type-vs-value filter excluded every visible candidate.
domain_filtered,
};
/// A collected author set paired with the verdict the resolver computed over it.
/// `set` is the RAW collection (own flat, diamond-deduped — the S2.1 form, owned
/// here); `verdict` is the S2.2 selection outcome over its domain-eligible subset.
pub const ResolvedAuthors = struct {
set: AuthorSet,
verdict: Verdict,
};
/// What ONE reference site resolves to. `authors` carries the collected author
/// identity plus its computed verdict; `template` / `pack` are symbolic generic-param
/// references (no author, no verdict — the template grammar the resolver carries as
/// refs instead of a mutable `sig_registration_mode`).
pub const ResolvedRef = union(enum) {
authors: AuthorSet,
authors: ResolvedAuthors,
template: TemplateParamId,
pack: PackRef,
};
@@ -294,7 +339,7 @@ pub const ResolvedProgram = struct {
for (self.allTables()) |t| {
var it = t.valueIterator();
while (it.next()) |ref| switch (ref.*) {
.authors => |a| if (a.flat.len > 0) self.alloc.free(a.flat),
.authors => |a| if (a.set.flat.len > 0) self.alloc.free(a.set.flat),
.template, .pack => {},
};
t.deinit();
@@ -455,6 +500,85 @@ fn authorSetHasStructConst(set: AuthorSet, field: []const u8) bool {
return false;
}
/// The `*StructDecl` a raw author wraps (bare or `Name :: struct(...)` const), or
/// null when the author is not a struct. Mirrors lowering's `structDeclOfRaw`.
fn structDeclOf(raw: RawDeclRef) ?*const ast.StructDecl {
return switch (raw) {
.struct_decl => |sd| sd,
.const_decl => |cd| if (cd.value.data == .struct_decl) &cd.value.data.struct_decl else null,
else => null,
};
}
/// The `*FnDecl` a raw author wraps (bare or `Name :: fn(...)` const), or null when
/// the author is not a function. Mirrors lowering's `fnDeclOfRaw`.
fn fnDeclOf(raw: RawDeclRef) ?*const ast.FnDecl {
return switch (raw) {
.fn_decl => |fd| fd,
.const_decl => |cd| if (cd.value.data == .fn_decl) &cd.value.data.fn_decl else null,
else => null,
};
}
/// A PLAIN free function — no type params, an ordinary (non-`#foreign`/`#builtin`/
/// `#compiler`) body — the only callable kind the bare-call verdict counts. Mirrors
/// lowering's `isPlainFreeFn`.
fn isPlainFreeFnDecl(fd: *const ast.FnDecl) bool {
if (fd.type_params.len > 0) return false;
return switch (fd.body.data) {
.foreign_expr, .builtin_expr, .compiler_expr => false,
else => true,
};
}
/// The reference domains a verdict is computed over. Each carries its own
/// eligibility filter (`eligibleKind`), so the own-wins / ambiguity count surveys
/// only the authors that can actually decide THIS kind of reference — a same-name
/// value never decides a type, a non-generic struct never authors a generic head.
const Domain = enum {
bare_type,
value_const,
callable,
generic_struct_head,
type_fn_head,
protocol_head,
foreign_class,
struct_const,
namespace_member,
ufcs,
};
/// Whether `raw` is an author ELIGIBLE to decide a reference in `domain` — the
/// type-vs-value domain filter applied BEFORE the own-wins / ambiguity count.
/// `field` is the accessed member name (struct-const domain only; ignored
/// elsewhere). Mirrors the per-kind author predicates the old lowering selectors
/// gate on (`isNamedTypeKind`, `isPlainFreeFn`, `typeFnAuthor`, the `classifyHeadKind`
/// struct/fn `type_params.len > 0` test, `structHasConstMember`).
fn eligibleKind(domain: Domain, raw: RawDeclRef, field: ?[]const u8) bool {
return switch (domain) {
// Foreign classes are routed to their own domain before the type verdict, so
// a bare TYPE author is a non-foreign named type. A type ALIAS (`Name :: <type>`,
// a `const_decl`) is recognised by lowering via the E0 source-keyed alias cache,
// which the resolver does not yet carry — alias authorship folds in when the
// alias facts move into the resolver (a later S2/S4 refinement), not here.
.bare_type => switch (raw) {
.struct_decl, .enum_decl, .union_decl, .error_set_decl, .protocol_decl => true,
else => false,
},
.value_const => raw == .const_decl,
.callable => if (fnDeclOf(raw)) |fd| isPlainFreeFnDecl(fd) else false,
.generic_struct_head => if (structDeclOf(raw)) |sd| sd.type_params.len > 0 else false,
.type_fn_head => if (fnDeclOf(raw)) |fd| fd.type_params.len > 0 else false,
.protocol_head => raw == .protocol_decl,
.foreign_class => raw == .foreign_class_decl,
.struct_const => structHasConstMember(raw, field orelse return false),
// A namespace member is already selected against ONE namespace target, so any
// kind the member declares is the unambiguous author.
.namespace_member => true,
.ufcs => fnDeclOf(raw) != null,
};
}
/// The single owning traversal. Holds the author collector + the `ResolvedProgram`
/// it populates; threads `Ctx` (ambient source + generic scope) down the tree.
const ResolvePass = struct {
@@ -554,7 +678,7 @@ const ResolvePass = struct {
if (self.ufcs_aliases.get(cname)) |target| {
self.recordAuthorsInto(&self.out.ufcs_refs, c.callee, target, here.source);
} else {
self.recordAuthors(&self.out.callable_refs, c.callee, cname, here.source);
self.recordAuthors(.callable, &self.out.callable_refs, c.callee, cname, here.source);
}
} else {
self.visit(c.callee, here);
@@ -581,7 +705,7 @@ const ResolvePass = struct {
.pack_index_type_expr => |*p| self.recordPack(&self.out.type_refs, node, p.pack_name, p.index, here.scope),
.comptime_pack_ref => |*p| self.recordPack(&self.out.value_refs, node, p.pack_name, null, here.scope),
.error_type_expr => |*e| {
if (e.name) |name| self.recordAuthors(&self.out.type_refs, node, name, here.source);
if (e.name) |name| self.recordAuthors(.bare_type, &self.out.type_refs, node, name, here.source);
},
.parameterized_type_expr => |*p| {
// the head (`Name(args)`) is binned by its resolved author's decl
@@ -810,7 +934,7 @@ const ResolvePass = struct {
return;
}
}
self.recordAuthors(&self.out.type_refs, node, te.name, ctx.source);
self.recordAuthors(.bare_type, &self.out.type_refs, node, te.name, ctx.source);
}
/// A value-position identifier: a generic value/type param in scope (shadowing)
@@ -823,28 +947,79 @@ const ResolvePass = struct {
return;
}
}
self.recordAuthors(&self.out.value_refs, node, id.name, ctx.source);
self.recordAuthors(.value_const, &self.out.value_refs, node, id.name, ctx.source);
}
/// RAW author collection for a bare name. Only recorded when the name has ≥1
/// visible author (own or flat); a builtin / local / undeclared spelling has
/// none and is omitted — this is what keeps the tables to genuine authors. A
/// name whose author is a `foreign_class_decl` is routed to `foreign_class_refs`
/// (its own S2.1c domain) instead of the passed bare type/value/callable table.
fn recordAuthors(self: *ResolvePass, table: *NodeRefTable, node: *const ast.Node, name: []const u8, from: []const u8) void {
/// Collect a bare name's authors AND compute its `domain` verdict. A name whose
/// author is a `foreign_class_decl` is routed to `foreign_class_refs` (its own
/// S2.1c domain, with the foreign-class verdict) instead of the passed
/// type/value/callable table. Records own-wins / single / ambiguous when an
/// eligible author is visible, and `not_visible` when the name is authored for
/// this domain only over a namespace edge; a builtin / local / undeclared
/// spelling (no visible author and none authored anywhere) is dropped, exactly
/// as S2.1 dropped the empty set.
fn recordAuthors(self: *ResolvePass, domain: Domain, table: *NodeRefTable, node: *const ast.Node, name: []const u8, from: []const u8) void {
const set = self.res.collectVisibleAuthors(name, from, .user_bare_flat);
if (set.distinctCount() == 0) return;
const dest = if (authorSetIsForeignClass(set)) &self.out.foreign_class_refs else table;
self.replaceRef(dest, node, .{ .authors = set });
const foreign = authorSetIsForeignClass(set);
const dom: Domain = if (foreign) .foreign_class else domain;
const dest = if (foreign) &self.out.foreign_class_refs else table;
const verdict = self.verdictOver(dom, name, set, null);
// Nothing visible AND not a domain author anywhere → a builtin / local /
// undeclared spelling, never a reference of this domain — drop it (the S2.1
// empty-set behavior). An empty set owns no `flat` slice to free.
if (verdict == .domain_filtered and set.distinctCount() == 0) return;
self.replaceRef(dest, node, .{ .authors = .{ .set = set, .verdict = verdict } });
}
/// RAW author collection into an explicit table, with NO foreign-class routing —
/// the destination domain is already chosen by the caller (UFCS rewrite sites
/// and alias decls, whose target is always a free function).
/// Collect a target name's authors into an explicit table with the `.ufcs`
/// verdict and NO foreign-class routing — the destination domain is already
/// chosen by the caller (UFCS rewrite sites and alias decls, whose target is
/// always a free function). The target is always present, so an empty set is
/// simply not recorded (no not-visible leak path here).
fn recordAuthorsInto(self: *ResolvePass, table: *NodeRefTable, node: *const ast.Node, name: []const u8, from: []const u8) void {
const set = self.res.collectVisibleAuthors(name, from, .user_bare_flat);
if (set.distinctCount() == 0) return;
self.replaceRef(table, node, .{ .authors = set });
const verdict = self.verdictOver(.ufcs, name, set, null);
self.replaceRef(table, node, .{ .authors = .{ .set = set, .verdict = verdict } });
}
/// The verdict over a collected author set for `domain`: own-wins when the
/// querying module's own author is eligible; ≥2 distinct eligible flat authors →
/// ambiguous; exactly one → single; none eligible but authored for this domain
/// in some (non-flat-visible) module → not_visible; otherwise domain_filtered
/// (visible same-name authors of the wrong kind, or nothing authored anywhere).
/// `field` is the accessed member (struct-const domain only).
fn verdictOver(self: *ResolvePass, domain: Domain, name: []const u8, set: AuthorSet, field: ?[]const u8) Verdict {
if (set.own) |o| {
if (eligibleKind(domain, o.raw, field)) return .own_wins;
}
var n: usize = 0;
for (set.flat) |fa| {
if (eligibleKind(domain, fa.raw, field)) {
n += 1;
if (n >= 2) return .ambiguous;
}
}
if (n == 1) return .single;
if (self.authoredAsDomainAnywhere(domain, name, field)) return .not_visible;
return .domain_filtered;
}
/// TRUE iff `name` is authored for `domain` in ANY module's raw facts — the
/// not-visible leak detector. Reached only with zero eligible flat-visible
/// authors, so a hit means the author is reachable only over a namespace edge
/// (had it been a flat edge it would already be in the surveyed set). Mirrors
/// lowering's `nameAuthoredAsTypeAnywhere`, generalized over every domain via
/// `eligibleKind`.
fn authoredAsDomainAnywhere(self: *ResolvePass, domain: Domain, name: []const u8, field: ?[]const u8) bool {
const decls = self.res.index.module_decls orelse return false;
var it = decls.valueIterator();
while (it.next()) |m| {
if (m.names.get(name)) |ref| {
if (eligibleKind(domain, ref, field)) return true;
}
}
return false;
}
/// `alias.member`: when `alias` is a namespace import edge of `from`, resolve
@@ -858,7 +1033,8 @@ const ResolvePass = struct {
const target = aliases.get(alias) orelse return false;
const set = self.res.collectNamespaceAuthors(target, member);
if (set.distinctCount() == 0) return false;
self.replaceRef(&self.out.namespace_refs, node, .{ .authors = set });
const verdict = self.verdictOver(.namespace_member, member, set, null);
self.replaceRef(&self.out.namespace_refs, node, .{ .authors = .{ .set = set, .verdict = verdict } });
return true;
}
@@ -875,22 +1051,24 @@ const ResolvePass = struct {
if (set.flat.len > 0) self.out.alloc.free(set.flat);
return false;
}
self.replaceRef(&self.out.struct_const_refs, node, .{ .authors = set });
const verdict = self.verdictOver(.struct_const, base, set, member);
self.replaceRef(&self.out.struct_const_refs, node, .{ .authors = .{ .set = set, .verdict = verdict } });
return true;
}
/// A parameterized head (`Name(args)`) binned by its resolved author's decl
/// kind: a generic struct (struct with type params) → `generic_struct_heads`;
/// a type-function (fn / const-wrapped fn with type params) → `type_fn_heads`; a
/// protocol → `protocol_heads`. RAW — the whole author set is recorded with no
/// winner picked, so a name authored as more than one head kind across modules
/// lands a distinct entry in every matching table. A head naming a generic param
/// in scope is symbolic (not an author); a name with no user author (builtins
/// like `Vector`, undeclared) or only non-head authors is omitted.
/// protocol → `protocol_heads`. The whole author set is recorded in every
/// matching table WITH that head kind's verdict (own-wins / single / ambiguous),
/// so a name authored as more than one head kind across modules lands a distinct
/// verdict-bearing entry per table. A head naming a generic param in scope is
/// symbolic (not an author); a name with no user author (builtins like `Vector`,
/// undeclared) or only non-head authors is omitted.
fn classifyHead(self: *ResolvePass, node: *const ast.Node, name: []const u8, is_raw: bool, ctx: Ctx) void {
if (std.mem.indexOfScalar(u8, name, '.')) |first_dot| {
const set = self.collectQualifiedHeadAuthors(name, first_dot, ctx.source) orelse return;
self.classifyHeadSet(node, set);
self.classifyHeadSet(node, name, set);
return;
}
@@ -898,7 +1076,7 @@ const ResolvePass = struct {
const set = self.res.collectVisibleAuthors(name, ctx.source, .user_bare_flat);
if (set.distinctCount() == 0) return;
self.classifyHeadSet(node, set);
self.classifyHeadSet(node, name, set);
}
/// `alias.Member(args)` reaches this pass as one `parameterized_type_expr`
@@ -919,25 +1097,28 @@ const ResolvePass = struct {
return set;
}
fn classifyHeadSet(self: *ResolvePass, node: *const ast.Node, set: AuthorSet) void {
/// One head table plus the verdict domain whose eligibility it counts.
const HeadBin = struct { table: *NodeRefTable, domain: Domain };
fn classifyHeadSet(self: *ResolvePass, node: *const ast.Node, name: []const u8, set: AuthorSet) void {
var gs = false;
var tf = false;
var pr = false;
if (set.own) |a| classifyHeadKind(a.raw, &gs, &tf, &pr);
for (set.flat) |a| classifyHeadKind(a.raw, &gs, &tf, &pr);
var tables: [3]*NodeRefTable = undefined;
var bins: [3]HeadBin = undefined;
var n: usize = 0;
if (gs) {
tables[n] = &self.out.generic_struct_heads;
bins[n] = .{ .table = &self.out.generic_struct_heads, .domain = .generic_struct_head };
n += 1;
}
if (tf) {
tables[n] = &self.out.type_fn_heads;
bins[n] = .{ .table = &self.out.type_fn_heads, .domain = .type_fn_head };
n += 1;
}
if (pr) {
tables[n] = &self.out.protocol_heads;
bins[n] = .{ .table = &self.out.protocol_heads, .domain = .protocol_head };
n += 1;
}
if (n == 0) {
@@ -946,13 +1127,18 @@ const ResolvePass = struct {
if (set.flat.len > 0) self.out.alloc.free(set.flat);
return;
}
// each table OWNS its `AuthorSet.flat`; give the first match the collected
// slice and a fresh copy to every subsequent table so `deinit` frees each
// exactly once.
self.replaceRef(tables[0], node, .{ .authors = set });
// each table OWNS its `AuthorSet.flat`; give the first bin the collected
// slice and a fresh copy to every subsequent bin so `deinit` frees each
// exactly once. The verdict is computed PER head kind — `Box` authored as a
// generic struct in one module and a type-fn in another lands an own-wins /
// single / ambiguous verdict independently in each table.
const v0 = self.verdictOver(bins[0].domain, name, set, null);
self.replaceRef(bins[0].table, node, .{ .authors = .{ .set = set, .verdict = v0 } });
var i: usize = 1;
while (i < n) : (i += 1) {
self.replaceRef(tables[i], node, .{ .authors = self.dupAuthorSet(set) });
const dup = self.dupAuthorSet(set);
const vi = self.verdictOver(bins[i].domain, name, dup, null);
self.replaceRef(bins[i].table, node, .{ .authors = .{ .set = dup, .verdict = vi } });
}
}
@@ -987,7 +1173,7 @@ const ResolvePass = struct {
fn releaseRef(self: *ResolvePass, ref: ResolvedRef) void {
switch (ref) {
.authors => |a| if (a.flat.len > 0) self.out.alloc.free(a.flat),
.authors => |a| if (a.set.flat.len > 0) self.out.alloc.free(a.set.flat),
.template, .pack => {},
}
}