diff --git a/bootstrap/src/compiler.rs b/bootstrap/src/compiler.rs index f1a9b50035..f23ee6560f 100644 --- a/bootstrap/src/compiler.rs +++ b/bootstrap/src/compiler.rs @@ -21896,12 +21896,105 @@ pub fn typecheck_ast(ast: &Node) -> TypeCheckResult { } } + // A constant must fit the type it declares. + // + // `const EXP_OFFSET: u32 = 1792...173` (185 digits) typechecked clean, and + // every backend emitted the digits verbatim: Rust as `pub const + // EXP_OFFSET: u32`, Verilog as `localparam [31:0]`. Only `cc` said anything + // -- "integer literal is too large to be represented in any integer type" -- + // and it is the fourth backend, so three of the four carried a value 590 + // bits wide in a 32-bit box without a word. + // + // Ten constants across five specs. The declared width is the claim; the + // literal is the value; nothing compared them. + check_const_widths(ast, &mut result); + if result.error_count > 0 { result.ok = false; } result } +/// The number of value bits a t27 integer type holds, or `None` if the type is +/// not a fixed-width integer. +/// +/// Signed types report one bit fewer: `i32` holds 2^31 - 1, not 2^32 - 1. A +/// check that used the full width would accept `const X: i32 = 3000000000`, +/// which is the same defect with a smaller number. +pub fn int_value_bits(ty: &str) -> Option { + match ty.trim() { + "u8" => Some(8), + "u16" => Some(16), + "u32" => Some(32), + "u64" => Some(64), + "usize" => Some(64), + "i8" => Some(7), + "i16" => Some(15), + "i32" => Some(31), + "i64" => Some(63), + "isize" => Some(63), + _ => None, + } +} + +/// Whether a decimal literal fits a type of `bits` value bits. +/// +/// The `Err` arm is the load-bearing one, not a fallback. The values that +/// provoked this are 185 digits long, so `parse::` returns `Err` rather +/// than a wrong number -- too wide to parse and too wide to fit are the same +/// answer here. A first draft carried an explicit `len() > 20` guard above this +/// and a comment explaining why it was needed; deleting the guard changed no +/// test, because it never ran. +pub fn literal_fits(digits: &str, bits: u32) -> bool { + let d = digits.trim_start_matches('0'); + if d.is_empty() { + return true; + } + match d.parse::() { + Ok(v) => v < (1u128 << bits), + Err(_) => false, + } +} + +fn check_const_widths(node: &Node, result: &mut TypeCheckResult) { + if node.kind == NodeKind::ConstDecl { + // The literal is the first child, not `value`: `decl.value` carries only + // the verbatim text of a tagged union. Reading the wrong field is why the + // first version of this check fired on nothing and reported clean. + let lit = node + .children + .first() + .filter(|c| c.kind == NodeKind::ExprLiteral) + .map(|c| if c.value.is_empty() { c.name.clone() } else { c.value.clone() }) + .unwrap_or_default(); + if let Some(bits) = int_value_bits(&node.extra_type) { + // The lexer strips digit separators, so the literal arrives without + // them. A filter here changed no test either -- verified by removing + // it -- and `const A: u32 = 4_294_967_295` is checked end to end in + // tests/const_width.rs, where the lexer's behaviour is what the + // assertion actually rests on. + let raw: String = lit.clone(); + if !raw.is_empty() && raw.chars().all(|c| c.is_ascii_digit()) && !literal_fits(&raw, bits) + { + result.error_count += 1; + let line = if node.line > 0 { + format!(":{}", node.line) + } else { + String::new() + }; + result.errors.push(format!( + "error: constant '{}'{} declares {} but its value has {} digits, \ + which no {} can hold", + node.name, line, node.extra_type, raw.len(), node.extra_type + )); + } + } + } + for c in &node.children { + check_const_widths(c, result); + } +} + /// `ret` is the enclosing function's declared return type, threaded so the /// RETURN position can be compared like the other three. /// diff --git a/bootstrap/stage0/FROZEN_HASH b/bootstrap/stage0/FROZEN_HASH index 96cbec4b56..da65b25aed 100644 --- a/bootstrap/stage0/FROZEN_HASH +++ b/bootstrap/stage0/FROZEN_HASH @@ -1 +1 @@ -f54e190f098bb7663bfdea7fdbde6f2450fd38174bf407073fc6f6b17acb4275 +7d3956bee4446a11d8752b74dea8e53f9d5f17bd44b99a1d54bacd47e59aa8b4 diff --git a/bootstrap/tests/const_width.rs b/bootstrap/tests/const_width.rs new file mode 100644 index 0000000000..88de24d262 --- /dev/null +++ b/bootstrap/tests/const_width.rs @@ -0,0 +1,101 @@ +//! A constant must fit the type it declares. +//! +//! `const EXP_OFFSET: u32 = 1792...173` -- 185 digits -- typechecked clean, and +//! every backend emitted the digits verbatim: Rust as `pub const EXP_OFFSET: +//! u32`, Verilog as `localparam [31:0]`. Only `cc` said anything, and it is the +//! fourth backend, so three of the four carried a 590-bit value in a 32-bit box +//! without a word. +//! +//! The declared width is a claim, the literal is a value, and nothing compared +//! them. Ten constants across five specs. + +use std::io::Write; +use std::process::Command; + +/// Typecheck a source string through the shipped binary and return only the +/// width errors. +/// +/// Through the binary, not a library call: this crate has no lib target, and a +/// test that reimplemented the check would pass against a compiler that never +/// shipped it. +fn errors_of(src: &str) -> Vec { + let dir = std::env::temp_dir().join(format!( + "t27-constwidth-{}-{}", + std::process::id(), + src.len() + )); + 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("which no")) + .map(|l| l.to_string()) + .collect() +} + +#[test] +fn a_literal_wider_than_its_declared_type_is_an_error() { + let e = errors_of("module m\n\nconst HUGE: u32 = 99999999999999999999999999999999999999\n"); + assert_eq!(e.len(), 1, "expected one width error, got {e:?}"); + assert!(e[0].contains("38 digits"), "{}", e[0]); + assert!(e[0].contains("u32"), "{}", e[0]); +} + +/// The value that provoked this does not fit any Rust integer either. +/// +/// A checker that parsed the literal into `u128` would overflow on exactly the +/// inputs it exists to reject and report nothing -- the failure would look like +/// a clean file. So the digit count is compared first. +#[test] +fn a_literal_too_wide_for_u128_is_still_rejected() { + let big = "1".repeat(185); + let e = errors_of(&format!("module m\n\nconst E: u32 = {big}\n")); + assert_eq!(e.len(), 1, "185 digits must be rejected, not overflow away"); + assert!(e[0].contains("185 digits"), "{}", e[0]); +} + +#[test] +fn a_literal_that_fits_is_left_alone() { + for (ty, v) in [ + ("u8", "255"), + ("u16", "65535"), + ("u32", "4294967295"), + ("u64", "18446744073709551615"), + ("i32", "2147483647"), + ] { + let e = errors_of(&format!("module m\n\nconst C: {ty} = {v}\n")); + assert!(e.is_empty(), "{ty} = {v} must be accepted, got {e:?}"); + } +} + +/// Signed types hold one bit fewer. +/// +/// A check that used the full width would accept `const X: i32 = 3000000000`, +/// which is the same defect with a smaller number and no compiler to catch it: +/// `cc` only complains above 2^64. +#[test] +fn a_signed_type_holds_one_bit_fewer() { + let e = errors_of("module m\n\nconst X: i32 = 3000000000\n"); + assert_eq!(e.len(), 1, "2^31 <= 3000000000 < 2^32, so i32 cannot hold it"); + let ok = errors_of("module m\n\nconst Y: u32 = 3000000000\n"); + assert!(ok.is_empty(), "u32 can, and the same number must be accepted there"); +} + +/// Underscores are separators, not digits. +#[test] +fn digit_separators_do_not_count_as_width() { + let e = errors_of("module m\n\nconst C: u32 = 4_294_967_295\n"); + assert!(e.is_empty(), "4_294_967_295 is u32::MAX, got {e:?}"); +} diff --git a/docs/now/2026-08-30-the-largest-first-error-is-not-the-largest-lever.md b/docs/now/2026-08-30-the-largest-first-error-is-not-the-largest-lever.md new file mode 100644 index 0000000000..b1d8d4e952 --- /dev/null +++ b/docs/now/2026-08-30-the-largest-first-error-is-not-the-largest-lever.md @@ -0,0 +1,10 @@ +# NOW -- The largest first error is not the largest lever (2026-08-30) + +## The largest first error is not the largest lever (Closes #2925) + +- C generates for 578 specs and cc accepts 174; the 404-file gap ranked by FIRST error put `default_input`/`valid_input` on top with 74 +- measured before building: of the 166 files carrying that error, **0** would compile if it were the only fix -- every one has other independent families +- ranking by first error ranks by POSITION IN THE FILE, not by blocking power; only **20 of 404** files are blocked by exactly one family, median is 4-5 +- the real find came from the 4 single-family files: `const EXP_OFFSET: u32 = 1792...173` (185 digits) typechecked clean and three backends emitted it verbatim +- ten constants across five specs; only cc ever said anything, and it is the fourth backend +- two branches of my own fix were DEAD with comments explaining why they were needed: `parse::` returns Err rather than overflowing, and the lexer already strips separators -- both removed after their mutations changed no test diff --git a/docs/reports/suite_expectations.json b/docs/reports/suite_expectations.json index bfaa040009..264dbf9763 100644 --- a/docs/reports/suite_expectations.json +++ b/docs/reports/suite_expectations.json @@ -2,7 +2,7 @@ "schema_version": 1, "generated_by": "t27c suite --bless-expectations", "max_gate_failures": 2, - "max_entries": 147, + "max_entries": 152, "entries": [ { "path": "specs/account/repo.t27", @@ -1417,7 +1417,43 @@ "reason": "Expected LBrace, got Semicolon (';')", "issue": 1959, "expires": "2026-11-30" + }, + { + "path": "specs/numeric/gft64.t27", + "phase": "typecheck", + "reason": "EXP_OFFSET and OFFSET_MAX declare u32 and carry values 12 to 187 digits wide. Newly DETECTED, not newly broken: the constants are unchanged and three backends still emit them verbatim -- only cc ever said so. The header says E_t = round((N-1)/phi^2) = 391, so which number is meant is a question about the format, not the compiler.", + "issue": 2925, + "expires": "2026-11-30" + }, + { + "path": "specs/numeric/gft128.t27", + "phase": "typecheck", + "reason": "EXP_OFFSET and OFFSET_MAX declare u32 and carry values 12 to 187 digits wide. Newly DETECTED, not newly broken: the constants are unchanged and three backends still emit them verbatim -- only cc ever said so. The header says E_t = round((N-1)/phi^2) = 391, so which number is meant is a question about the format, not the compiler.", + "issue": 2925, + "expires": "2026-11-30" + }, + { + "path": "specs/numeric/gft256.t27", + "phase": "typecheck", + "reason": "EXP_OFFSET and OFFSET_MAX declare u32 and carry values 12 to 187 digits wide. Newly DETECTED, not newly broken: the constants are unchanged and three backends still emit them verbatim -- only cc ever said so. The header says E_t = round((N-1)/phi^2) = 391, so which number is meant is a question about the format, not the compiler.", + "issue": 2925, + "expires": "2026-11-30" + }, + { + "path": "specs/numeric/gft512.t27", + "phase": "typecheck", + "reason": "EXP_OFFSET and OFFSET_MAX declare u32 and carry values 12 to 187 digits wide. Newly DETECTED, not newly broken: the constants are unchanged and three backends still emit them verbatim -- only cc ever said so. The header says E_t = round((N-1)/phi^2) = 391, so which number is meant is a question about the format, not the compiler.", + "issue": 2925, + "expires": "2026-11-30" + }, + { + "path": "specs/numeric/gft1024.t27", + "phase": "typecheck", + "reason": "EXP_OFFSET and OFFSET_MAX declare u32 and carry values 12 to 187 digits wide. Newly DETECTED, not newly broken: the constants are unchanged and three backends still emit them verbatim -- only cc ever said so. The header says E_t = round((N-1)/phi^2) = 391, so which number is meant is a question about the format, not the compiler.", + "issue": 2925, + "expires": "2026-11-30" } ], - "_why_entries_fell": "151 -> 150: specs/pins/emitter_xdc.t27 [typecheck] was fixed by #2906 -- an integer literal above i64::MAX fell to the float branch, so a bit mask read as a float -- and the ledger entry outlived the failure. The ratchet reported it as an UNEXPECTED PASS and named the repair itself: 'fixed -- remove from the ledger'. Removed, and the cap lowered in the same commit so the next entry cannot hide in the slack." + "_why_entries_fell": "151 -> 150: specs/pins/emitter_xdc.t27 [typecheck] was fixed by #2906 -- an integer literal above i64::MAX fell to the float branch, so a bit mask read as a float -- and the ledger entry outlived the failure. The ratchet reported it as an UNEXPECTED PASS and named the repair itself: 'fixed -- remove from the ledger'. Removed, and the cap lowered in the same commit so the next entry cannot hide in the slack.", + "_why_entries_rose": "a new typecheck rule (constant literal must fit its declared type, #2925) detects a defect that was always present. The five gft specs are recorded rather than edited because their correct values are a question about the GF-T ladder's definition, not about the compiler. Nothing regressed." }