wip(resolver): collectors + unified predicate + tightened adapters [stdlib B, BLOCKED on 0106]

Collectors (resolver.zig: collectVisibleAuthors/collectNamespaceAuthors + AuthorSet
+ VisibilityMode, 4 unit tests) + unified visibility predicate + isNameVisible/
isCImportVisible adapters routed to flat modes. Tightening surfaces issue 0106
(stdlib comptime expansion relies on the over-permissive import_graph join), so
run_examples is 467/471 here. attempt-2 folds in the coupled comptime-context fix.
This commit is contained in:
agra
2026-06-07 04:52:56 +03:00
parent 35457cb614
commit 7158337c73
6 changed files with 668 additions and 35 deletions

View File

@@ -12,6 +12,7 @@ const interp_mod = @import("interp.zig");
const errors = @import("../errors.zig");
const jni_descriptor = @import("jni_descriptor.zig");
const program_index_mod = @import("program_index.zig");
const resolver_mod = @import("resolver.zig");
const ProgramIndex = program_index_mod.ProgramIndex;
const GlobalInfo = program_index_mod.GlobalInfo;
const StructTemplate = program_index_mod.StructTemplate;
@@ -110,6 +111,30 @@ const CleanupEntry = struct {
binding: ?[]const u8 = null,
};
/// Pure non-transitive visibility walk: `name` is visible from `source` when
/// it's in `source`'s own scope or in any module reachable over one `graph`
/// edge. The core of the lowering visibility predicate, exposed so a unit test
/// can exercise the edge-walk without standing up a whole `Lowering`. Falls open
/// (true) when `scopes`/`graph` are null (scoping infra unwired).
pub fn nameVisibleOverEdges(
scopes: ?*std.StringHashMap(std.StringHashMap(void)),
graph: ?*std.StringHashMap(std.StringHashMap(void)),
source: []const u8,
name: []const u8,
) bool {
const sc = scopes orelse return true;
const own_scope = sc.get(source) orelse return true;
if (own_scope.contains(name)) return true;
const g = graph orelse return true;
const direct = g.get(source) orelse return true;
var it = direct.iterator();
while (it.next()) |kv| {
const dep = sc.get(kv.key_ptr.*) orelse continue;
if (dep.contains(name)) return true;
}
return false;
}
// ── Lowering ────────────────────────────────────────────────────────────
pub const Lowering = struct {
@@ -1765,45 +1790,71 @@ pub const Lowering = struct {
// null-FuncId path (`lowerFunction`), which runs after all types resolve.
}
/// The unified non-transitive `#import` visibility predicate, parameterized
/// by `VisibilityMode`. `isNameVisible` / `isCImportVisible` are thin
/// adapters over it.
///
/// This is the lowering-side GATE: it walks `module_scopes` (the per-file
/// name set) joined over the edge set the mode selects. It is distinct from
/// `resolver.collectVisibleAuthors`, which collects raw AUTHORS over
/// `module_decls` — the single graph-walk that lives in `resolver.zig`. The
/// two read different facts (name set vs author refs) for different jobs, so
/// the gate's own iterator stays here, not in the resolver.
///
/// `module_scopes[F]` holds ONLY the names authored in F (plus its namespace
/// aliases); cross-module visibility is joined here at query time. Doing the
/// join at lookup (instead of pre-merging in `resolveImports`) lets cyclic
/// imports like std.sx ↔ allocators.sx still resolve, since the cycle's
/// skipped edge is still recorded in the graph and the partner's scope is
/// filled in by the time lowering queries it.
fn isVisible(self: *Lowering, name: []const u8, vis: resolver_mod.VisibilityMode) bool {
switch (vis) {
// Registration / lazy lowering paths don't police user visibility.
.lowering_internal => return true,
// Transitive visibility is ProtocolResolver.findVisibleImpls' job;
// this predicate is single-hop only.
.impl_transitive => @panic("isVisible: transitive visibility is owned by findVisibleImpls"),
.c_import_bare => {
// Foreign-C gate: only C-import fn_decls without a library_ref
// are policed; a non-foreign body or a library-bound foreign
// decl is unconditionally visible.
const fd = self.program_index.fn_ast_map.get(name) orelse return true;
if (fd.body.data != .foreign_expr) return true;
if (fd.body.data.foreign_expr.library_ref != null) return true;
return self.visibleOverEdges(name, .flat);
},
.user_bare_flat => return self.visibleOverEdges(name, .flat),
.legacy_direct_any => return self.visibleOverEdges(name, .all),
}
}
const VisEdgeSet = enum { flat, all };
/// Resolve the mode's edge set and run the per-file visibility walk. Falls
/// open (visible) when the scoping infrastructure isn't wired (comptime
/// callers, directory imports without main_file, etc.). The caller is
/// responsible for restricting the check to names that ARE known top-level
/// decls; otherwise every local variable would be policed.
fn visibleOverEdges(self: *Lowering, name: []const u8, edges: VisEdgeSet) bool {
const source = self.current_source_file orelse return true;
const graph = switch (edges) {
.flat => self.program_index.flat_import_graph,
.all => self.program_index.import_graph,
};
return nameVisibleOverEdges(self.program_index.module_scopes, graph, source, name);
}
/// Check if a C-imported function is visible from the current source file.
/// Returns true for non-C functions (always visible) or if no scoping info available.
/// Returns true for non-C functions (always visible) or if no scoping info
/// available. Byte-identical adapter over `isVisible`.
fn isCImportVisible(self: *Lowering, fn_name: []const u8) bool {
const fd = self.program_index.fn_ast_map.get(fn_name) orelse return true;
// Only restrict C import fn_decls: foreign_expr with no library_ref
if (fd.body.data != .foreign_expr) return true;
if (fd.body.data.foreign_expr.library_ref != null) return true;
return self.isNameVisible(fn_name);
return self.isVisible(fn_name, .c_import_bare);
}
/// Non-transitive `#import` visibility check for top-level decls.
///
/// `module_scopes[F]` holds ONLY the names authored in file F (plus its
/// namespace aliases). Cross-module visibility is joined here at query
/// time by walking each direct flat-import edge in `import_graph` — a
/// name is visible from F when it's authored in F or in any module F
/// directly `#import`s. Doing the join here (instead of pre-merging in
/// `resolveImports`) lets cyclic imports like std.sx ↔ allocators.sx
/// still resolve, since the cycle's skipped edge is still recorded in
/// `import_graph` and the partner's scope is filled in by the time
/// lowering queries it.
///
/// Falls open when the scoping infrastructure isn't wired (comptime
/// callers, directory imports without main_file, etc.). The caller is
/// responsible for restricting the call to names that ARE known
/// top-level decls; otherwise every local variable would be policed.
/// Byte-identical adapter over `isVisible`.
fn isNameVisible(self: *Lowering, name: []const u8) bool {
const scopes = self.program_index.module_scopes orelse return true;
const source = self.current_source_file orelse return true;
const own_scope = scopes.get(source) orelse return true;
if (own_scope.contains(name)) return true;
const graph = self.program_index.import_graph orelse return true;
const direct = graph.get(source) orelse return true;
var it = direct.iterator();
while (it.next()) |kv| {
const dep = scopes.get(kv.key_ptr.*) orelse continue;
if (dep.contains(name)) return true;
}
return false;
return self.isVisible(name, .user_bare_flat);
}
/// Lazily lower a function body on demand. Called when lowerCall can't find