From ba2e2ae9470bd492b696e9c6eff67e56a43f966e Mon Sep 17 00:00:00 2001 From: Vasilev Dmitrii Date: Sun, 30 Aug 2026 07:18:06 +0700 Subject: [PATCH] feat(typecheck): a constant must fit the type it declares const EXP_OFFSET: u32 = 1792...173 // 185 digits Typecheck OK (0 errors, 0 warnings) Rust emitted `pub const EXP_OFFSET: u32 = 1792...173;`, Verilog emitted `localparam [31:0] OFFSET_MAX = 3584...346;`, C emitted the `#define`. 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 four carried a ~590-bit value in a 32-bit box without a word. Ten constants across five specs, the whole `gft` ladder. The digit count roughly doubles per rung, so they look computed; the file header says the quantity is `E_t = round((N-1)/phi^2) = 391`. Signed types hold one bit fewer, so `const X: i32 = 3000000000` is caught too -- the same defect at a size cc never complains about, because it fits uint64_t. HOW THIS WAS FOUND, and the reading that was wrong first. The C backlog ranked by FIRST error put the scaffold helpers on top: `default_input` 47, `valid_input` 27. Measured before building anything: of the 166 files carrying that error, ZERO would compile if it were the only fix. 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; the median carries four or five. This defect came from four of those twenty. TWO BRANCHES OF THE FIRST DRAFT WERE DEAD, each with a comment explaining why it was necessary. A `len() > 20` guard "because parsing 185 digits into u128 would overflow" -- `parse::` returns `Err`, and the `Err` arm already answers. A filter stripping `_` "because separators are not digits" -- the lexer strips them before the check sees the literal. Both mutations changed no test, so both are gone and the comments now say what is actually load-bearing. The five specs go in the expectations ledger rather than being edited: which number is meant is a question about the GF-T ladder's definition, not about the compiler. 150 -> 155 with the reason in the file. Nothing regressed -- this is newly detected, not newly broken. Closes #2925 --- bootstrap/src/compiler.rs | 93 ++++++++++++++++ bootstrap/stage0/FROZEN_HASH | 2 +- bootstrap/tests/const_width.rs | 101 ++++++++++++++++++ ...st-first-error-is-not-the-largest-lever.md | 10 ++ docs/reports/suite_expectations.json | 40 ++++++- 5 files changed, 243 insertions(+), 3 deletions(-) create mode 100644 bootstrap/tests/const_width.rs create mode 100644 docs/now/2026-08-30-the-largest-first-error-is-not-the-largest-lever.md diff --git a/bootstrap/src/compiler.rs b/bootstrap/src/compiler.rs index a71dbbcaac..fc5056d681 100644 --- a/bootstrap/src/compiler.rs +++ b/bootstrap/src/compiler.rs @@ -21884,12 +21884,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 ebb719c418..67810d066b 100644 --- a/bootstrap/stage0/FROZEN_HASH +++ b/bootstrap/stage0/FROZEN_HASH @@ -1 +1 @@ -82e020cf95b211d048f71ee03840e1c5b3acbdc4192ebabeab8d951f4a26eeda +81eeb8400aef07f5c6052c8254533bef5599fc21f418079218cd2a0967ecb9fd 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 85546353b6..7b5ba67be6 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": 150, + "max_entries": 155, "entries": [ { "path": "specs/account/repo.t27", @@ -1438,7 +1438,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": "150 -> 155: 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." }