fix: reject non-type expression in type position instead of fabricating {} (issue 0189)

Two type-resolution paths silently resolved a non-type AST node in type
position to a zero-field `{}` struct that reached codegen with no
diagnostic:
  - a dotted `type_expr` / field-access (`g.a`, `g` a runtime value) whose
    prefix is not a namespace alias
  - an `error_type_expr` (`!Name`) whose `Name` is not a declared error set

Now both reject loudly:
  - `resolveTypeWithBindings` (lower.zig): "expected a type, found a value
    '<name>' in type position" + `.unresolved`
  - `checkTypeNodeForUnknown` (semantic_diagnostics.zig): validates a named
    `!E` against the declared error-set names — "unknown error set
    '<name>'" / "expected an error set after '!', found type '<name>'".

A bare `!` (void channel) and a declared `!E` in return position stay
valid; namespace-qualified types (`pkg.Type`) are unaffected.

Regression: examples/diagnostics/1195-diagnostics-non-type-in-type-position.
This commit is contained in:
agra
2026-06-25 20:35:02 +03:00
parent f52e16a3fc
commit 45e69ac1bb
7 changed files with 197 additions and 5 deletions

View File

@@ -0,0 +1,35 @@
// Regression (issue 0189): a non-type expression used in TYPE position must be
// rejected with a clear diagnostic — never silently resolved to a fabricated
// zero-field empty struct `{}` that ships to codegen as a real type.
//
// Two fabrication paths are covered:
//
// 1. A dotted `type_expr` / field-access (`g.a`, `g` a runtime VALUE, `a` a
// field) in type position — both the bare annotation `x : g.a = ---;` and
// the `Tuple(i32, g.a)` element form hit the same `resolveTypeWithBindings`
// dotted-name guard. A dotted name whose prefix is not a namespace alias is
// a value field access, not a qualified `pkg.Type` path → "expected a type,
// found a value".
//
// 2. A named `!E` (error-set type) whose `E` is not a declared error set —
// an undeclared name or a value name after `!` silently fabricated a `{}`
// stub via `resolveErrorType` -> `resolveNominalLeaf`. The
// `error_type_expr` arm of `checkTypeNodeForUnknown` now validates it →
// "unknown error set" (undeclared / value) or "expected an error set"
// (a declared non-error-set type).
//
// A bare `!` (the void failable channel) and a DECLARED `!E` in return position
// stay valid — exercised in examples/errors and not flagged here.
#import "modules/std.sx";
S :: struct { a: i32; }
g : S = .{ a = 1 };
main :: () -> i32 {
x : g.a = ---; // field-access value in type position
y : Tuple(i32, g.a) = ---; // same, as a tuple element
z : !Nonexistent = ---; // `!` of an undeclared name
w : Tuple(i32, !Nonexistent) = ---; // nested in a tuple
v : Closure(!Nonexistent) -> i32 = ---; // nested in a closure param
0
}

View File

@@ -0,0 +1,29 @@
error: unknown error set 'Nonexistent'
--> examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx:31:9
|
31 | z : !Nonexistent = ---; // `!` of an undeclared name
| ^^^^^^^^^^^^
error: unknown error set 'Nonexistent'
--> examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx:32:20
|
32 | w : Tuple(i32, !Nonexistent) = ---; // nested in a tuple
| ^^^^^^^^^^^^
error: unknown error set 'Nonexistent'
--> examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx:33:17
|
33 | v : Closure(!Nonexistent) -> i32 = ---; // nested in a closure param
| ^^^^^^^^^^^^
error: expected a type, found a value 'g.a' in type position
--> examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx:29:9
|
29 | x : g.a = ---; // field-access value in type position
| ^^^
error: expected a type, found a value 'g.a' in type position
--> examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx:30:20
|
30 | y : Tuple(i32, g.a) = ---; // same, as a tuple element
| ^^^

View File

@@ -1,6 +1,27 @@
# 0189 — non-type expression in type position silently fabricates an empty struct
**Status:** OPEN
**Status:** RESOLVED
> **RESOLVED.** Root cause: two distinct type-resolution paths silently
> fabricated a zero-field `{}` struct for a non-type AST node used in type
> position — (1) a dotted `type_expr` / field-access (`g.a`, `g` a runtime
> value) whose prefix is not a namespace alias, and (2) an `error_type_expr`
> (`!Name`) whose `Name` is not a declared error set (an undeclared name or a
> value). Both reached codegen as a real empty struct with no diagnostic.
>
> Fix: (1) the dotted-name guard in `resolveTypeWithBindings`
> (`src/ir/lower.zig` ~10711093) rejects a value field-access in type position
> ("expected a type, found a value '<name>' in type position"); (2) a new
> `.error_type_expr` arm in `checkTypeNodeForUnknown`
> (`src/ir/semantic_diagnostics.zig`) validates a named `!E` against a
> collected set of declared error-set names — "unknown error set '<name>'" for
> an undeclared/value name, "expected an error set after '!', found type
> '<name>'" for a declared non-error-set type. A bare `!` (void channel) and a
> declared `!E` in return position stay valid.
>
> Regression test: `examples/diagnostics/1195-diagnostics-non-type-in-type-position.sx`
> (covers both the `g.a`/`Tuple(i32, g.a)` field-access path and the
> `!Nonexistent` / nested-tuple / nested-closure error-set path).
## Symptom

View File

@@ -1068,6 +1068,29 @@ pub const Lowering = struct {
self.setCurrentSourceFile(saved);
return ty;
}
// A dotted `type_expr` whose prefix is NOT a namespace alias
// is the only remaining qualified form — and sx has no
// `Type.NestedType` access, so this is a VALUE field access
// (`g.a` where `g` is a value) sitting in a type position.
// Without this guard `resolveNominalLeaf("g.a")` would
// fabricate a zero-field empty-struct stub (`{}`) and ship it
// to codegen as a real type (issue 0189) — a silent-default
// miscompile. Reject loudly and poison with `.unresolved`.
// A genuinely registered dotted type (none today, but a
// forward-declared stub could exist) is still honored before
// we reject, so we never reject a name that resolves to a
// real type.
if (!self.aliasDeclaredAnywhere(te.name[0..dot])) {
const sid = self.module.types.internString(te.name);
if (self.module.types.findByName(sid)) |tid| {
const info = self.module.types.get(tid);
const is_empty_stub = info == .@"struct" and info.@"struct".fields.len == 0;
if (!is_empty_stub) return tid;
}
if (self.diagnostics) |d|
d.addFmt(.err, node.span, "expected a type, found a value '{s}' in type position", .{te.name});
return .unresolved;
}
}
return self.resolveNominalLeaf(te.name, te.is_raw, node.span);
},

View File

@@ -45,6 +45,13 @@ pub const UnknownTypeChecker = struct {
types: *TypeTable,
index: *ProgramIndex,
main_file: ?[]const u8,
/// Declared error-set names (`E :: error { ... }`) gathered across every
/// compiled module + nested scope. Populated in `run`; consulted by the
/// `.error_type_expr` arm of `checkTypeNodeForUnknown` to tell a valid
/// `!E` (E a declared set) apart from an undeclared name or a value name
/// used after `!` in type position (both of which silently fabricate a
/// zero-field `{}` stub — issue 0189). `null` only before `run` populates it.
error_sets: ?*const std.StringHashMap(void) = null,
pub fn run(self: UnknownTypeChecker, decls: []const *const Node) void {
// Reserved-type-name binding diagnostic: rejects any
@@ -65,6 +72,14 @@ pub const UnknownTypeChecker = struct {
var declared = std.StringHashMap(void).init(self.alloc);
defer declared.deinit();
self.collectDeclaredTypeNames(decls, &declared);
// Declared error-set names — every module + nested scope. Used by the
// `.error_type_expr` arm to validate `!E` (issue 0189). Collected
// unfiltered (imported sets count: `g :: () -> i64 !LibErr` is valid).
var error_sets = std.StringHashMap(void).init(self.alloc);
defer error_sets.deinit();
for (decls) |decl| self.collectErrorSetNames(decl, &error_sets);
var checker = self;
checker.error_sets = &error_sets;
const saved_file = self.diagnostics.current_source_file;
defer self.diagnostics.current_source_file = saved_file;
for (decls) |decl| {
@@ -77,11 +92,11 @@ pub const UnknownTypeChecker = struct {
// previous phase left behind (issue 0122).
if (decl.source_file) |sf| self.diagnostics.current_source_file = sf;
switch (decl.data) {
.fn_decl => self.checkFnSignatureTypes(&decl.data.fn_decl, &declared),
.struct_decl => |sd| self.checkStructFieldTypes(&sd, &declared),
.fn_decl => checker.checkFnSignatureTypes(&decl.data.fn_decl, &declared),
.struct_decl => |sd| checker.checkStructFieldTypes(&sd, &declared),
.const_decl => |cd| switch (cd.value.data) {
.fn_decl => self.checkFnSignatureTypes(&cd.value.data.fn_decl, &declared),
.struct_decl => |sd| self.checkStructFieldTypes(&sd, &declared),
.fn_decl => checker.checkFnSignatureTypes(&cd.value.data.fn_decl, &declared),
.struct_decl => |sd| checker.checkStructFieldTypes(&sd, &declared),
else => {},
},
else => {},
@@ -89,6 +104,34 @@ pub const UnknownTypeChecker = struct {
}
}
/// Gather declared error-set names (`E :: error { ... }`) into `out`, from a
/// top-level decl and every nested scope (fn / closure bodies, local type
/// decls). A top-level error set parses as a bare `.error_set_decl`; a
/// local one is a `.const_decl` whose value is an `.error_set_decl`. Walking
/// every module (not just the main file) keeps an imported `!LibErr` valid.
fn collectErrorSetNames(self: UnknownTypeChecker, node: *const Node, out: *std.StringHashMap(void)) void {
switch (node.data) {
.error_set_decl => |esd| out.put(esd.name, {}) catch {},
.const_decl => |cd| {
if (cd.value.data == .error_set_decl)
out.put(cd.value.data.error_set_decl.name, {}) catch {};
self.collectErrorSetNames(cd.value, out);
},
.fn_decl => |fd| self.collectErrorSetNames(fd.body, out),
.block => |b| for (b.stmts) |s| self.collectErrorSetNames(s, out),
.if_expr => |ie| {
self.collectErrorSetNames(ie.then_branch, out);
if (ie.else_branch) |e| self.collectErrorSetNames(e, out);
},
.while_expr => |we| self.collectErrorSetNames(we.body, out),
.for_expr => |fe| self.collectErrorSetNames(fe.body, out),
.match_expr => |me| for (me.arms) |arm| self.collectErrorSetNames(arm.body, out),
.push_stmt => |ps| self.collectErrorSetNames(ps.body, out),
.lambda => |lm| self.collectErrorSetNames(lm.body, out),
else => {},
}
}
/// Reserved-type-name binding walk. Visits every node
/// reachable from `node` and rejects each *binding name* — `var` / `:=` /
/// typed-local declarations, destructure names, function / lambda / method
@@ -814,10 +857,49 @@ pub const UnknownTypeChecker = struct {
self.checkTypeNodeForUnknown(a, declared, in_scope, type_vals);
}
},
// `!E` (named failable channel) in type position. A bare `!`
// (name == null) is the inferred/void channel and is always valid.
// A named `!E` is valid ONLY when `E` is a declared error set;
// otherwise the lowering path silently fabricates a zero-field
// `{}` stub (issue 0189), so reject it here with a precise
// diagnostic — "unknown error set" for an undeclared name, "expected
// an error set" for a name that resolves to a non-error-set type or
// a value.
.error_type_expr => |ete| if (ete.name) |name|
self.reportIfNotErrorSet(name, node.span),
else => {},
}
}
/// Validate the `E` in an `!E` type. `E` must be a declared error set.
/// Distinguishes three failure shapes so the user gets an actionable
/// message: an undeclared name (`unknown error set`), and a declared name
/// that is NOT an error set — a value or a non-error-set type (`expected an
/// error set`). Mirrors the silent-fabrication guard for `g.a` in
/// `resolveTypeWithBindings` (issue 0189): never let a non-error-set name
/// after `!` reach the lowering stub.
fn reportIfNotErrorSet(self: UnknownTypeChecker, name: []const u8, span: ?ast.Span) void {
// Inline-spelled / qualified spellings (`mod.E`) carry non-identifier
// characters — trust them, matching `reportIfUnknownType`.
if (!isIdentLike(name)) return;
const sets = self.error_sets orelse return;
if (sets.contains(name)) return;
// A name that names a real (non-error-set) TYPE — a struct/enum/union,
// a builtin, or a fabricated stub — is a type-in-error-position misuse.
if (isBuiltinTypeName(name)) {
self.diagnostics.addFmt(.err, span, "expected an error set after '!', found type '{s}'", .{name});
return;
}
const sid = self.types.internString(name);
if (self.types.findByName(sid)) |_| {
self.diagnostics.addFmt(.err, span, "expected an error set after '!', found type '{s}'", .{name});
return;
}
// Otherwise the name is undeclared (or names a value): no error-set
// author anywhere. Either way it is not a usable error set.
self.diagnostics.addFmt(.err, span, "unknown error set '{s}'", .{name});
}
fn reportIfUnknownType(
self: UnknownTypeChecker,
name: []const u8,