Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 147 additions & 0 deletions bootstrap/src/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22930,6 +22930,133 @@ struct FnEntry {
params: Vec<(String, TypeInfo)>,
}

/// The identifier a type annotation ultimately names, with the wrappers the
/// language spells around it stripped.
///
/// `resolve_type_str` only matches whole strings, so `[]Trit` and `[3]Trit`
/// both fall through to `Custom("[]Trit")` -- the wrapper, not the type. A
/// check that consulted `Custom(..)` directly would therefore compare a name
/// nothing ever declares and report every array as unresolved.
fn type_base_name(t: &str) -> Option<String> {
let mut t = t.trim();
loop {
let before = t;
t = t.trim_start_matches('*').trim_start_matches('&');
t = t.trim_end_matches('?');
t = t.trim();
if let Some(rest) = t.strip_prefix("const ") {
t = rest.trim();
}
if let Some(rest) = t.strip_prefix('[') {
// `[]T` and `[N]T` alike: everything up to the first `]`.
if let Some(i) = rest.find(']') {
t = rest[i + 1..].trim();
}
}
if t == before {
break;
}
}
let ok = !t.is_empty()
&& t.starts_with(|c: char| c.is_ascii_alphabetic() || c == '_')
&& t.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
if ok {
Some(t.to_string())
} else {
None
}
}

/// Every type name this tree declares: structs, enums, and the Zig-shaped
/// `pub const Trit = enum(i8) { .. }`, which the parser records as a ConstDecl.
fn collect_declared_types(node: &Node, out: &mut std::collections::HashSet<String>) {
match node.kind {
NodeKind::StructDecl | NodeKind::EnumDecl => {
out.insert(node.name.clone());
}
// `pub const PackedTrit = u8; // Type alias` and
// `pub const Trit = enum(i8) { .. }` are both type declarations spelled
// as constants, and neither carries a type ANNOTATION -- that is what
// separates them from `pub const ONE : i8 = 1`. Missing the alias form
// produced 33 false warnings on specs/base/types.t27 alone.
//
// The test is deliberately loose. A name wrongly added here only
// SUPPRESSES a warning; a name wrongly left out invents one. Of the two
// failure directions only the second is loud and wrong.
NodeKind::ConstDecl if node.extra_type.trim().is_empty() => {
out.insert(node.name.clone());
}
_ => {}
}
for c in &node.children {
collect_declared_types(c, out);
}
}

/// Type annotations naming something this tree never declares.
///
/// The typechecker resolved a type name to `TypeInfo::Custom(..)` and asked no
/// further question, so `struct S { a: NoSuchType }` produced
/// "Typecheck OK (0 errors, 0 warnings)" and the first reader to notice was
/// rustc, downstream, in a different language. Measured over the corpus: **62
/// distinct undefined type names across 61 of 651 specs**, headed by `List`,
/// `Float`, `Trit`, `Int` and `Bool` -- and of those, `Float`, `Int` and `Bool`
/// are declared by NO spec at all, while `Trit` is declared by four and simply
/// not imported by the files that use it.
///
/// Reported as warnings, not errors. Making 61 specs fail is a decision about
/// what `check` means and belongs to the owner; making the omission visible
/// does not.
fn collect_unresolved_types(
node: &Node,
declared: &std::collections::HashSet<String>,
type_params: &std::collections::HashSet<String>,
out: &mut Vec<(String, String)>,
) {
let mut note = |ty: &str, where_: String, out: &mut Vec<(String, String)>| {
if let Some(base) = type_base_name(ty) {
// TWO resolvers, and they do not agree. `resolve_type_str` knows
// 15 spellings (`i32`, `f64`, `str`, ...); the emitter's
// `t27_type_to_rust` also knows the language's OWN keyword
// spellings -- `int` -> `i32`, `float` -> `f64`,
// `string` -> `&'static str`. Asking only the first reported
// `int`, `string` and `float` as unknown types in
// specs/ar/coa_planning.t27, which the emitter lowers correctly.
//
// A name the emitter rewrites is a name the language knows. A name
// it hands back unchanged, and that nothing declares, is the one
// rustc will later fail to find. That disagreement between the two
// resolvers is worth its own repair; this check works around it
// rather than pretending it is not there.
let emitter_knows = RustCodegen::t27_type_to_rust(&base) != base;
if !declared.contains(&base)
&& !type_params.contains(&base)
&& !emitter_knows
&& matches!(resolve_type_str(&base), TypeInfo::Custom(_))
{
out.push((base, where_));
}
}
};
match node.kind {
NodeKind::FnDecl => {
note(&node.extra_return_type, format!("return type of `{}`", node.name), out);
for (pname, ptype) in &node.params {
note(ptype, format!("parameter `{}` of `{}`", pname, node.name), out);
}
}
NodeKind::StructDecl => {
for f in &node.children {
note(&f.extra_type, format!("field `{}` of `{}`", f.name, node.name), out);
}
}
_ => {}
}
for c in &node.children {
collect_unresolved_types(c, declared, type_params, out);
}
}

pub fn typecheck_ast(ast: &Node) -> TypeCheckResult {
let mut result = TypeCheckResult {
ok: true,
Expand All @@ -22940,6 +23067,26 @@ pub fn typecheck_ast(ast: &Node) -> TypeCheckResult {
let mut symbols: Vec<SymbolEntry> = Vec::new();
let mut fns: Vec<FnEntry> = Vec::new();

// A type name that resolves to nothing was silently accepted, and rustc was
// the first reader to say so. See `collect_unresolved_types`.
{
let mut declared = std::collections::HashSet::new();
collect_declared_types(ast, &mut declared);
let mut tparams = std::collections::HashSet::new();
collect_type_params(ast, &mut tparams);
let mut unresolved: Vec<(String, String)> = Vec::new();
collect_unresolved_types(ast, &declared, &tparams, &mut unresolved);
let mut seen = std::collections::HashSet::new();
for (name, wher) in unresolved {
if seen.insert(format!("{name}|{wher}")) {
result
.errors
.push(format!("warning: unknown type `{name}` in {wher}"));
result.warnings += 1;
}
}
}

for child in &ast.children {
match child.kind {
NodeKind::ConstDecl => {
Expand Down
2 changes: 1 addition & 1 deletion bootstrap/stage0/FROZEN_HASH
Original file line number Diff line number Diff line change
@@ -1 +1 @@
5857419820d4ed4ee5dcaa44df8662d6bfaa8a7a3c4129e5f82df3e4a9389091 bootstrap/src/compiler.rs
e4081dfc5d25511094205c8dec2735ead48cd6495875b89f17cf7c54bacd382a bootstrap/src/compiler.rs
112 changes: 112 additions & 0 deletions bootstrap/tests/unknown_type.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//! A type name that resolves to nothing was accepted in silence.
//!
//! `struct S { a: NoSuchTypeAnywhere }` typechecked as
//! "Typecheck OK (0 errors, 0 warnings)". The typechecker resolved the name to
//! `TypeInfo::Custom(..)` and asked no further question, so the first reader to
//! notice was rustc -- downstream, in another language, and only for the specs
//! whose emitted Rust got far enough to be type-checked at all.
//!
//! Measured over the corpus: 62 distinct undefined type names in 61 of 651
//! specs by rustc's reckoning, and 84 names in 169 specs by this check's --
//! which sees the whole spec rather than only what the emitter managed to emit.
//! Of the headline names, `Float`, `Int` and `Bool` are declared by NO spec at
//! all, while `Trit` is declared by four and not imported by the files that
//! use it.
//!
//! Reported as warnings: across all 651 specs the exit code of `check` changes
//! for zero of them.

use std::io::Write;
use std::process::Command;

/// Typecheck a source string through the SHIPPED binary and return the unknown
/// type warnings. Through the binary, as its neighbour does: this crate has no
/// lib target, and a test that reimplemented the check would pass against a
/// compiler that never shipped it.
fn unknown_types(src: &str) -> Vec<String> {
static SCRATCH_N: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let scratch_n = SCRATCH_N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let dir = std::env::temp_dir().join(format!(
"t27-unknowntype-{}-{}",
std::process::id(),
scratch_n
));
std::fs::create_dir_all(&dir).expect("temp dir");
let path = dir.join("m.t27");
let mut f = std::fs::File::create(&path).expect("write spec");
f.write_all(src.as_bytes()).expect("write spec");
let out = Command::new(env!("CARGO_BIN_EXE_t27c"))
.arg("typecheck")
.arg(&path)
.output()
.expect("run t27c");
let _ = std::fs::remove_dir_all(&dir);
let text = format!(
"{}{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr)
);
text.lines()
.filter(|l| l.contains("unknown type"))
.map(|l| l.to_string())
.collect()
}

#[test]
fn a_field_naming_a_type_that_does_not_exist_is_reported() {
let w = unknown_types("module m {\n struct S { a: NoSuchTypeAnywhere, }\n}\n");
assert_eq!(w.len(), 1, "expected one warning, got {w:?}");
assert!(w[0].contains("NoSuchTypeAnywhere"), "{}", w[0]);
assert!(w[0].contains("field `a`"), "the warning must say WHERE: {}", w[0]);
}

#[test]
fn a_parameter_and_a_return_type_are_both_checked() {
let w = unknown_types("module m {\n fn f(x: Missing1) -> Missing2 { }\n}\n");
assert_eq!(w.len(), 2, "parameter and return type, got {w:?}");
}

#[test]
fn a_declared_struct_is_not_reported() {
// The whole value of the check is that it does not cry wolf.
let w = unknown_types(
"module m {\n struct Point { x: i32, }\n fn f(p: Point) -> i32 { return p.x; }\n}\n",
);
assert!(w.is_empty(), "a declared type must not warn: {w:?}");
}

#[test]
fn a_type_alias_spelled_as_a_const_is_a_declaration() {
// `pub const PackedTrit = u8; // Type alias` and
// `pub const Trit = enum(i8) { .. }` are type declarations written as
// constants, and neither carries a type ANNOTATION -- which is what
// separates them from `pub const ONE : i8 = 1`. Missing this form produced
// 33 false warnings on specs/base/types.t27 alone.
let w = unknown_types(
"module m {\n pub const Word = u8;\n struct S { a: Word, }\n}\n",
);
assert!(w.is_empty(), "a const type alias must count as declared: {w:?}");
}

#[test]
fn the_language_own_keyword_spellings_are_not_unknown() {
// TWO resolvers, and they disagree. `resolve_type_str` knows 15 spellings;
// the emitter also knows `int`, `float` and `string`. Asking only the first
// reported all three as unknown types in specs/ar/coa_planning.t27, which
// the emitter lowers correctly to i32, f64 and &'static str.
let w = unknown_types(
"module m {\n struct S { a: int, b: float, c: string, d: bool, }\n}\n",
);
assert!(w.is_empty(), "keyword spellings must not warn: {w:?}");
}

#[test]
fn an_array_of_a_declared_type_is_not_unknown() {
// `resolve_type_str` matches whole strings, so `[]Point` and `[3]Point`
// both fall through to `Custom("[]Point")` -- the wrapper, not the type. A
// check reading that directly would report every array as unresolved.
let w = unknown_types(
"module m {\n struct Point { x: i32, }\n fn f(a: []Point, b: [3]Point) -> i32 { return 0; }\n}\n",
);
assert!(w.is_empty(), "array wrappers must be stripped: {w:?}");
}
11 changes: 11 additions & 0 deletions docs/now/2026-09-08-a-type-that-resolved-to-nothing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# NOW -- A type that resolved to nothing (2026-09-08)

## A type that resolved to nothing (Closes #3408)

- `struct S { a: NoSuchTypeAnywhere }` typechecked as **"Typecheck OK (0 errors, 0 warnings)"**, exit 0. The typechecker resolved the name to `TypeInfo::Custom(..)` and asked no further question, so the first reader to notice was rustc -- downstream, in another language, and only for specs whose emitted Rust got far enough to be type-checked. Controls: a parse error exits **1**, a valid spec exits 0. `check` can say no; it did not say no to this.
- Two independent readers, disagreeing informatively: **rustc** finds 62 names in 61 of 651 specs; the new typechecker-side check finds **84 names in 169 specs**. The typechecker sees the whole spec, rustc only what the emitter emitted. Spot-checked both ways: `igla/coder/_tmp_pipeline_import.t27` uses `AgentProfile` and declares it nowhere (flagged, and rustc misses it); `igla/coder/eval.t27` declares it and is NOT flagged.
- The names split three ways and only one third is a compiler question. `Float`, `Int`, `Bool`: **declared by no spec at all**, and two specs write `use base::types::Float;` -- importing a name that does not exist. `Trit`: declared by four specs as `pub const Trit = enum(i8)` and simply not imported; `ar/coa_planning.t27` uses it with **no `use` statement at all**.
- **TWO resolvers that disagree.** `resolve_type_str` knows 15 spellings; the emitter's `t27_type_to_rust` also knows the language's own keywords (`int` -> `i32`, `float` -> `f64`, `string` -> `&'static str`). A check built on the first alone reported all three as unknown in `coa_planning`. The check now asks the emitter too, and the disagreement is filed rather than papered over.
- Two false-positive classes were found and closed BEFORE shipping: `pub const PackedTrit = u8; // Type alias` is a type declaration written as a constant (33 false warnings on `base/types.t27` alone), and `[]Point` falls through `resolve_type_str` as `Custom("[]Point")` -- the wrapper, not the type, so a naive reading would report every array as unresolved.
- Reported as **warnings**, and the non-regression is measured rather than argued: across all 651 specs the exit code of `check` changes for **zero** of them. Making 169 specs fail is a decision about what `check` means, and it is the owner's.
- Six tests through the shipped binary, mutation-checked twice with one test each: dropping the emitter consult fails only the keyword test; dropping the const-alias case fails only the alias test.
Loading