diff --git a/architecture/ADR-008-parameterised-const-type-declaration.md b/architecture/ADR-008-parameterised-const-type-declaration.md new file mode 100644 index 0000000000..1b3c7479c6 --- /dev/null +++ b/architecture/ADR-008-parameterised-const-type-declaration.md @@ -0,0 +1,127 @@ +# ADR-008: parameterised const type declaration `const Name(T) = struct { ... }` + +Status: accepted +Date: 2026-08-15 +Context: #2162 + +This ADR fixes the surface syntax and the AST shape BEFORE the parser is changed, so +that the change cannot widen the grammar by accident. Anything not listed as accepted +here is rejected, and there is a negative fixture for each rejection. + +## Decision + +`pub const Name(T) = struct { ... };` is an accepted **parameterised type +declaration**. The parser accepts it as + + ConstDecl(name, generic_parameters, StructExpr) + +and does not require a type identifier after `const`. + +This is a parser defect, not a corpus error. The corpus is the evidence of intent: +**33 declarations across 28 files**, every one of them with `struct` on the right-hand +side, and not one instance of any other right-hand side. + +## Evidence from the corpus, at `b1884f95` + +Parameter lists that actually occur, exhaustively: + +| parameter list | occurrences | +|---|---| +| `T` | 22 | +| `K, V` | 4 | +| `W, T` | 1 | +| `T, E` | 1 | +| `S, T` | 1 | +| `R, T` | 1 | +| `L, R` | 1 | +| `A, B, C` | 1 | +| `A, B` | 1 | + +Right-hand sides that occur: `struct` × 33. Nothing else. + +So the accepted form is narrow by evidence, not by taste: one to three bare +identifiers, comma-separated, and `struct` on the right. + +## What is accepted + +Grammar, and nothing outside it: + +``` +ParamConstDecl := [ "pub" ] "const" Ident "(" GenericParams ")" "=" "struct" "{" StructBody "}" [ ";" ] +GenericParams := Ident { "," Ident } +``` + +- one or more parameters +- each parameter is a bare `Ident` +- separator is exactly `,` +- the right-hand side is `struct` and only `struct` + +## What is rejected, and why each rejection is deliberate + +| rejected form | reason | +|---|---| +| `const Name() = struct {}` | an empty parameter list is not a parameterised type; if it is meant as a plain type it should be written without parentheses. Ambiguous, so refused | +| `const Name(T,) = struct {}` | trailing comma is not attested in the corpus. Accepting it is a grammar widening with no evidence behind it | +| `const Name([]T) = struct {}` | a type expression in a parameter position. Parameters are names being bound, not types being used | +| `const Name(T: Trait) = struct {}` | constrained parameters are a language feature, not a parser detail. Out of scope, and accepting the syntax now would commit the language to it | +| `const Name(T) = enum(i8) {}` | not attested. `enum` already has its own accepted form without parameters, and combining the two is a separate decision | +| `const Name(T) = 42;` | a parameterised value is not a type declaration | +| `const Name(T);` | a declaration with no right-hand side | + +## `Name(T)` versus function-like syntax + +The two are distinguished **by position and by the token after the closing paren**, not +by lookahead over the parameter list: + +- `const Name(...) = struct` — reached from `parse_const_decl` after `const` and a name. + `(` here opens a generic parameter list +- `fn name(...)` — reached from `parse_fn_decl`, a different entry point entirely, where + `(` opens a value parameter list with `name: Type` pairs +- `Name(args)` as an expression — reached from expression parsing, never from + `parse_const_decl`'s name position + +There is therefore no ambiguity to resolve: the const path never sees a call expression +in that position, and a value parameter list (`x: u8`) is rejected here by the +`Ident { "," Ident }` rule, which admits no colon. + +## Accepted terminators + +After the closing `}` of the struct body, both `;` and no `;` are accepted, matching the +existing non-parameterised `const Name = struct { ... }` path exactly. Nothing new is +introduced: whatever that path accepts, this path accepts. + +## AST shape + +- `Node.kind` becomes `NodeKind::StructDecl`, exactly as the non-parameterised form does. + The declaration is a struct declaration; being parameterised does not change its kind +- `Node.name` is the declared name, WITHOUT the parameter list. `Stack(T)` has + `name = "Stack"` +- `Node.params` carries the parameters as `(name, "")` pairs, reusing the existing + `Vec<(String, String)>` field. The second element is the empty string because a + parameter has no type: it IS a type. No new field is added to `Node` +- struct body children are parsed by the existing `parse_struct_body`, so field nodes are + indistinguishable from those of a non-parameterised struct + +`params` being non-empty is what marks the declaration as parameterised. Nothing else in +the AST changes. + +## Codegen is deliberately NOT changed + +This ADR changes parsing only. A parameterised type has no single instantiation and the +Verilog backend has no notion of one, so no lowering is defined here. + +This has a consequence that must be measured rather than assumed: these 28 files +previously failed to parse **as whole files**, so every declaration in them was invisible. +After this change they parse, and the backend will see declarations it has never seen. +Whether the result is correct emission, harmless emission, or wrong emission is an open +question and is the subject of the two-mode differential in the same tick. It is not +claimed here to be safe. + +## What is not claimed + +- Not that parameterised types are implemented. They parse; they do not instantiate +- Not that the 28 files now compile. Parsing is the only claim +- Not that this is the whole of #2162's population. The earlier figure of ten files was + the count of files whose FIRST failing construct was this one, measured on the repaired + corpus. The real population is 28 files and 33 declarations, and the difference is + exactly why a first-failure count must never be reported as a population diff --git a/bootstrap/src/compiler.rs b/bootstrap/src/compiler.rs index b56090f218..a8d70ac150 100644 --- a/bootstrap/src/compiler.rs +++ b/bootstrap/src/compiler.rs @@ -1894,6 +1894,100 @@ impl Parser { )); } + // ADR-008 (#2162): optional generic parameter list, as in + // `pub const Stack(T) = struct { ... };`. The grammar accepted here is + // exactly `Ident { "," Ident }` with `struct` on the right-hand side and + // nothing else, because that is the whole of what the corpus attests: + // 33 declarations in 28 files, every one of them a struct, parameter + // lists of one to three bare identifiers. Every rejection below has a + // negative fixture in tests/fixtures/generic_const/. + if self.current.kind == TokenKind::LParen { + self.advance(); // consume ( + + if self.current.kind == TokenKind::RParen { + return Err(format!( + "ADR-008: empty generic parameter list in 'const {}()'. An empty \ + list is ambiguous with a plain type declaration; write \ + 'const {}' without parentheses instead", + decl.name, decl.name + )); + } + + let mut generic_params: Vec<(String, String)> = Vec::new(); + loop { + if self.current.kind != TokenKind::Ident { + return Err(format!( + "ADR-008: generic parameter of 'const {}' must be a bare \ + identifier, got {:?} ('{}'). Parameters are names being \ + bound, not types being used", + decl.name, self.current.kind, self.current.lexeme + )); + } + // The second element stays empty on purpose: a generic parameter + // has no type, it IS a type. No new Node field is introduced. + generic_params.push((self.current.lexeme.clone(), String::new())); + self.advance(); + + if self.current.kind == TokenKind::Comma { + self.advance(); // consume , + if self.current.kind == TokenKind::RParen { + return Err(format!( + "ADR-008: trailing comma in generic parameter list of \ + 'const {}'. Not attested anywhere in the corpus, so \ + not accepted", + decl.name + )); + } + continue; + } + break; + } + + if self.current.kind != TokenKind::RParen { + return Err(format!( + "ADR-008: expected ')' or ',' after generic parameter of \ + 'const {}', got {:?} ('{}'). Constrained parameters such as \ + '(T: Ord)' are a language feature, not a parser detail", + decl.name, self.current.kind, self.current.lexeme + )); + } + self.advance(); // consume ) + + if self.current.kind != TokenKind::Equals { + return Err(format!( + "ADR-008: expected '=' after generic parameter list of 'const \ + {}', got {:?} ('{}'). A parameterised declaration with no \ + right-hand side is not a type declaration", + decl.name, self.current.kind, self.current.lexeme + )); + } + self.advance(); // consume = + + if self.current.kind != TokenKind::KwStruct { + return Err(format!( + "ADR-008: right-hand side of parameterised 'const {}' must be \ + 'struct', got {:?} ('{}'). All 33 attested declarations are \ + structs; a parameterised enum or value is a separate decision", + decl.name, self.current.kind, self.current.lexeme + )); + } + self.advance(); // consume 'struct' + + // Being parameterised does not change the kind: this is a struct + // declaration, and a non-empty `params` is what marks it as generic. + decl.kind = NodeKind::StructDecl; + decl.params = generic_params; + self.expect(TokenKind::LBrace)?; + self.parse_struct_body(&mut decl)?; + self.expect(TokenKind::RBrace)?; + // Both ';' and no ';' are accepted, matching the non-parameterised + // `const Name = struct { ... }` path exactly. No new terminator. + if self.current.kind == TokenKind::Semicolon { + self.advance(); + } + return Ok(decl); + } + // Optional type annotation `: Type` if self.current.kind == TokenKind::Colon { self.advance(); // consume : diff --git a/bootstrap/stage0/FROZEN_HASH b/bootstrap/stage0/FROZEN_HASH index 8f0ec1b67c..b4b2e4a72f 100644 --- a/bootstrap/stage0/FROZEN_HASH +++ b/bootstrap/stage0/FROZEN_HASH @@ -1 +1 @@ -375b2f88cc2f1c58e5ec26bae8efd1f78fa712491a98216cfd98a69d206ae041 +315fbe1df4f09eb5a5bd2ac8b9fd748537ccd0b5dc8f0528d602d324a4c48715 bootstrap/src/compiler.rs diff --git a/bootstrap/tests/fixtures/generic_const/neg_01_empty_params.t27 b/bootstrap/tests/fixtures/generic_const/neg_01_empty_params.t27 new file mode 100644 index 0000000000..7e32f9ff37 --- /dev/null +++ b/bootstrap/tests/fixtures/generic_const/neg_01_empty_params.t27 @@ -0,0 +1,7 @@ +// ADR-008 negative: an empty parameter list is not a parameterised type. +// Ambiguous with a plain type declaration, so refused rather than guessed. +module NegEmptyParams { + pub const Nothing() = struct { + x : "u8", + }; +} diff --git a/bootstrap/tests/fixtures/generic_const/neg_02_trailing_comma.t27 b/bootstrap/tests/fixtures/generic_const/neg_02_trailing_comma.t27 new file mode 100644 index 0000000000..9bddcd271d --- /dev/null +++ b/bootstrap/tests/fixtures/generic_const/neg_02_trailing_comma.t27 @@ -0,0 +1,7 @@ +// ADR-008 negative: a trailing comma is not attested anywhere in the corpus. +// Accepting it would widen the grammar with no evidence behind it. +module NegTrailingComma { + pub const Holder(T,) = struct { + x : "T", + }; +} diff --git a/bootstrap/tests/fixtures/generic_const/neg_03_type_expr_param.t27 b/bootstrap/tests/fixtures/generic_const/neg_03_type_expr_param.t27 new file mode 100644 index 0000000000..6b36af9d9a --- /dev/null +++ b/bootstrap/tests/fixtures/generic_const/neg_03_type_expr_param.t27 @@ -0,0 +1,7 @@ +// ADR-008 negative: a type expression in a parameter position. Parameters are +// names being bound, not types being used. +module NegTypeExprParam { + pub const Slice([]T) = struct { + x : "u8", + }; +} diff --git a/bootstrap/tests/fixtures/generic_const/neg_04_constrained_param.t27 b/bootstrap/tests/fixtures/generic_const/neg_04_constrained_param.t27 new file mode 100644 index 0000000000..c6010a516f --- /dev/null +++ b/bootstrap/tests/fixtures/generic_const/neg_04_constrained_param.t27 @@ -0,0 +1,7 @@ +// ADR-008 negative: a constrained parameter is a language feature, not a parser +// detail. Accepting the syntax now would commit the language to it. +module NegConstrainedParam { + pub const Sorted(T: Ord) = struct { + x : "T", + }; +} diff --git a/bootstrap/tests/fixtures/generic_const/neg_05_enum_rhs.t27 b/bootstrap/tests/fixtures/generic_const/neg_05_enum_rhs.t27 new file mode 100644 index 0000000000..c769d4a95c --- /dev/null +++ b/bootstrap/tests/fixtures/generic_const/neg_05_enum_rhs.t27 @@ -0,0 +1,7 @@ +// ADR-008 negative: only `struct` is attested on the right-hand side (33 of 33). +// A parameterised enum is a separate decision. +module NegEnumRhs { + pub const Tagged(T) = enum(i8) { + A = 0, + }; +} diff --git a/bootstrap/tests/fixtures/generic_const/neg_06_value_rhs.t27 b/bootstrap/tests/fixtures/generic_const/neg_06_value_rhs.t27 new file mode 100644 index 0000000000..d5b88dcd3d --- /dev/null +++ b/bootstrap/tests/fixtures/generic_const/neg_06_value_rhs.t27 @@ -0,0 +1,4 @@ +// ADR-008 negative: a parameterised value is not a type declaration. +module NegValueRhs { + pub const Answer(T) = 42; +} diff --git a/bootstrap/tests/fixtures/generic_const/neg_07_no_rhs.t27 b/bootstrap/tests/fixtures/generic_const/neg_07_no_rhs.t27 new file mode 100644 index 0000000000..87fb1a2214 --- /dev/null +++ b/bootstrap/tests/fixtures/generic_const/neg_07_no_rhs.t27 @@ -0,0 +1,4 @@ +// ADR-008 negative: a declaration with no right-hand side at all. +module NegNoRhs { + pub const Opaque(T); +} diff --git a/bootstrap/tests/fixtures/generic_const/pos_01_single_param.t27 b/bootstrap/tests/fixtures/generic_const/pos_01_single_param.t27 new file mode 100644 index 0000000000..7c72985a44 --- /dev/null +++ b/bootstrap/tests/fixtures/generic_const/pos_01_single_param.t27 @@ -0,0 +1,7 @@ +// ADR-008 positive: one generic parameter, the attested majority form (22 of 33). +module GenericConstSingle { + pub const Stack(T) = struct { + items : "[]T", + len : "usize", + }; +} diff --git a/bootstrap/tests/fixtures/generic_const/pos_02_two_params.t27 b/bootstrap/tests/fixtures/generic_const/pos_02_two_params.t27 new file mode 100644 index 0000000000..3472c7fa62 --- /dev/null +++ b/bootstrap/tests/fixtures/generic_const/pos_02_two_params.t27 @@ -0,0 +1,7 @@ +// ADR-008 positive: two generic parameters, attested as `K, V` in 4 files. +module GenericConstPair { + pub const Map(K, V) = struct { + key : "K", + value : "V", + }; +} diff --git a/bootstrap/tests/fixtures/generic_const/pos_03_three_params.t27 b/bootstrap/tests/fixtures/generic_const/pos_03_three_params.t27 new file mode 100644 index 0000000000..169e8adcaa --- /dev/null +++ b/bootstrap/tests/fixtures/generic_const/pos_03_three_params.t27 @@ -0,0 +1,8 @@ +// ADR-008 positive: three generic parameters, attested once as `A, B, C`. +module GenericConstTriple { + pub const Tuple3(A, B, C) = struct { + a : "A", + b : "B", + c : "C", + }; +} diff --git a/bootstrap/tests/fixtures/generic_const/pos_04_no_pub.t27 b/bootstrap/tests/fixtures/generic_const/pos_04_no_pub.t27 new file mode 100644 index 0000000000..fff184390b --- /dev/null +++ b/bootstrap/tests/fixtures/generic_const/pos_04_no_pub.t27 @@ -0,0 +1,6 @@ +// ADR-008 positive: `pub` is optional, exactly as on the non-parameterised path. +module GenericConstPrivate { + const Cell(T) = struct { + value : "T", + }; +} diff --git a/bootstrap/tests/fixtures/generic_const/pos_05_no_semicolon.t27 b/bootstrap/tests/fixtures/generic_const/pos_05_no_semicolon.t27 new file mode 100644 index 0000000000..eb4b73d0e6 --- /dev/null +++ b/bootstrap/tests/fixtures/generic_const/pos_05_no_semicolon.t27 @@ -0,0 +1,7 @@ +// ADR-008 positive: the trailing semicolon is optional, matching the +// non-parameterised `const Name = struct { }` path. No new terminator is introduced. +module GenericConstNoSemi { + pub const Box(T) = struct { + value : "T", + } +} diff --git a/bootstrap/tests/fixtures/generic_const/pos_06_alongside_plain.t27 b/bootstrap/tests/fixtures/generic_const/pos_06_alongside_plain.t27 new file mode 100644 index 0000000000..021a843ca9 --- /dev/null +++ b/bootstrap/tests/fixtures/generic_const/pos_06_alongside_plain.t27 @@ -0,0 +1,11 @@ +// ADR-008 positive: a parameterised and a plain struct declaration in one module. +// Guards against the generic path swallowing the declaration that follows it. +module GenericConstMixed { + pub const Option(T) = struct { + present : "bool", + value : "T", + }; + pub const Header = struct { + magic : "u32", + }; +} diff --git a/bootstrap/tests/generic_const_decl.rs b/bootstrap/tests/generic_const_decl.rs new file mode 100644 index 0000000000..5bacf38d76 --- /dev/null +++ b/bootstrap/tests/generic_const_decl.rs @@ -0,0 +1,242 @@ +//! ADR-008 / #2162 -- parameterised const type declaration `const Name(T) = struct`. +//! +//! Strategy: shell out to the built t27c binary via `CARGO_BIN_EXE_t27c` and run +//! `parse` over the fixtures in `tests/fixtures/generic_const/`. +//! +//! Two things are asserted, and the second one is the one that carries weight: +//! +//! 1. the six positive fixtures parse; +//! 2. each of the seven negative fixtures is rejected FOR ITS OWN STATED +//! REASON, matched on the specific ADR-008 message. +//! +//! Point 2 exists because of a measured trap. Before the fix, `neg_02` +//! (trailing comma) and `neg_03` (type expression in a parameter position) +//! already failed -- with the *same* generic `Unexpected token in expression: +//! KwStruct` error as every positive fixture. A negative fixture that fails for +//! the same reason as everything else proves nothing about the rule it claims to +//! test, so matching only on "non-zero exit" would give a green suite with no +//! evidential content. Two more fixtures, `neg_06` and `neg_07`, were the +//! opposite: the old parser ACCEPTED them (exit 0) while silently dropping the +//! right-hand side, so their assertion here records a deliberate tightening from +//! silent acceptance to rejection, not a preserved behaviour. +//! +//! Regression direction: the `regression_original_failing_form` test pins the +//! exact construct from the corpus that motivated #2162. Run against the parser +//! before the fix it fails; that is what makes it a proof of the fix rather than +//! a guard. The negative tests, by contrast, are guards: five of the seven also +//! failed before the fix, only for the wrong reason. +//! +//! Refs #2162. + +use std::path::PathBuf; +use std::process::{Command, Output}; + +fn fixture_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join("generic_const") +} + +fn parse_fixture(name: &str) -> Output { + let bin = env!("CARGO_BIN_EXE_t27c"); + let path = fixture_dir().join(name); + assert!(path.is_file(), "fixture missing: {}", path.display()); + Command::new(bin) + .arg("parse") + .arg(&path) + .output() + .expect("failed to spawn t27c parse") +} + +fn parse_source(source: &str, stem: &str) -> Output { + let bin = env!("CARGO_BIN_EXE_t27c"); + let path = std::env::temp_dir().join(format!("t27c_adr008_{stem}.t27")); + std::fs::write(&path, source).expect("failed to write temp spec"); + Command::new(bin) + .arg("parse") + .arg(&path) + .output() + .expect("failed to spawn t27c parse") +} + +fn assert_parses(name: &str) { + let out = parse_fixture(name); + assert!( + out.status.success(), + "ADR-008 positive fixture {name} must parse, got {:?}\nstderr: {}", + out.status, + String::from_utf8_lossy(&out.stderr) + ); +} + +/// Reject `name`, and reject it for the reason it is meant to test. +fn assert_rejected_because(name: &str, needle: &str) { + let out = parse_fixture(name); + assert!( + !out.status.success(), + "ADR-008 negative fixture {name} must be rejected, but it parsed" + ); + let combined = format!( + "{}{}", + String::from_utf8_lossy(&out.stdout), + String::from_utf8_lossy(&out.stderr) + ); + assert!( + combined.contains("ADR-008"), + "{name} was rejected, but not by the ADR-008 rule, so the fixture \ + proves nothing about that rule.\noutput: {combined}" + ); + assert!( + combined.contains(needle), + "{name} must be rejected for its own reason (expected {needle:?}).\n\ + output: {combined}" + ); +} + +// ---------------------------------------------------------------- positives + +#[test] +fn positive_single_parameter() { + assert_parses("pos_01_single_param.t27"); +} + +#[test] +fn positive_two_parameters() { + assert_parses("pos_02_two_params.t27"); +} + +#[test] +fn positive_three_parameters() { + assert_parses("pos_03_three_params.t27"); +} + +#[test] +fn positive_without_pub() { + assert_parses("pos_04_no_pub.t27"); +} + +#[test] +fn positive_without_trailing_semicolon() { + assert_parses("pos_05_no_semicolon.t27"); +} + +#[test] +fn positive_alongside_plain_struct() { + assert_parses("pos_06_alongside_plain.t27"); +} + +// ---------------------------------------------------------------- negatives + +#[test] +fn negative_empty_parameter_list() { + assert_rejected_because("neg_01_empty_params.t27", "empty generic parameter list"); +} + +#[test] +fn negative_trailing_comma() { + assert_rejected_because("neg_02_trailing_comma.t27", "trailing comma"); +} + +#[test] +fn negative_type_expression_in_parameter_position() { + assert_rejected_because("neg_03_type_expr_param.t27", "must be a bare"); +} + +#[test] +fn negative_constrained_parameter() { + assert_rejected_because("neg_04_constrained_param.t27", "expected ')' or ','"); +} + +#[test] +fn negative_enum_right_hand_side() { + assert_rejected_because("neg_05_enum_rhs.t27", "must be 'struct'"); +} + +#[test] +fn negative_value_right_hand_side() { + // Previously accepted with the right-hand side silently dropped. + assert_rejected_because("neg_06_value_rhs.t27", "must be 'struct'"); +} + +#[test] +fn negative_no_right_hand_side() { + // Previously accepted with the declaration silently truncated. + assert_rejected_because("neg_07_no_rhs.t27", "expected '='"); +} + +// ---------------------------------------------------------------- AST shape + +/// The AST contract of ADR-008, checked rather than assumed: the declared name +/// carries no parameter list, the parameters land in `params`, and the field +/// types survive. Parse success alone would not show any of this -- a parser +/// that dropped the whole body would also exit 0. +#[test] +fn ast_contract_name_params_and_field_types() { + let out = parse_fixture("pos_02_two_params.t27"); + assert!(out.status.success(), "fixture must parse"); + let dump = String::from_utf8_lossy(&out.stdout); + + let decl = dump + .find("kind: StructDecl") + .map(|i| &dump[i..]) + .expect("parameterised const must produce a StructDecl node"); + + assert!( + decl.contains("name: \"Map\""), + "name must be the bare declared name without its parameter list" + ); + assert!( + decl.contains("\"K\"") && decl.contains("\"V\""), + "generic parameters must be preserved in the node" + ); + assert!( + !decl.contains("name: \"Map(K, V)\""), + "the parameter list must not be folded into the name" + ); +} + +// ---------------------------------------------------------------- regression + +/// The construct straight out of the corpus that motivated #2162. This one is a +/// proof, not a guard: it fails against the parser as it stood before the fix. +#[test] +fn regression_original_failing_form() { + let source = "\ +module RegressionGenericConst { + pub const Stack(T) = struct { + items : \"[]T\", + len : \"usize\", + }; +} +"; + let out = parse_source(source, "regression_stack"); + assert!( + out.status.success(), + "the corpus form `pub const Stack(T) = struct` must parse (#2162); \ + before the fix this failed with `Unexpected token in expression: \ + KwStruct`\nstderr: {}", + String::from_utf8_lossy(&out.stderr) + ); +} + +/// Guard against the generic path swallowing whatever follows it: a plain const +/// after a parameterised declaration must still be seen. +#[test] +fn regression_declaration_after_generic_is_not_swallowed() { + let source = "\ +module GenericThenPlain { + pub const Boxed(T) = struct { + value : \"T\", + }; + pub const WIDTH : u32 = 27; +} +"; + let out = parse_source(source, "regression_after"); + assert!(out.status.success(), "module must parse"); + let dump = String::from_utf8_lossy(&out.stdout); + assert!( + dump.contains("name: \"WIDTH\""), + "the declaration following a parameterised const must survive" + ); +} diff --git a/docs/NOW.md b/docs/NOW.md index 953027e7be..542421f9ac 100644 --- a/docs/NOW.md +++ b/docs/NOW.md @@ -711,6 +711,18 @@ Last updated: 2026-08-15 - **The 26 `ok -> timeout` files are the boundary, not a slowdown.** Timed directly, 3 runs each way: median candidate/base ratio **1.010** (min 0.985, max 1.026), on files taking 10.8-11.7 s against a 12 s wall. A 1-3 % jitter is enough to move them across it, so the count difference measures the threshold and not the compiler - Rules R15 and R16 added to `docs/loop/LOOP-RULES.md` and resealed. `tri corpus-parse`, `corpus-status`, `diffmodes`, `loop-rules` registered in `scripts/ci/loop-tools-tracked.sh`, which fails on an untracked tool -- the state that already destroyed two of these scripts along with every number they produced +# NOW -- `pub const Name(T) = struct` is a declaration the parser must accept (2026-08-15) + +Last updated: 2026-08-15 + +## bootstrap: parameterised const type declarations (Closes #2162) + +- **The AST contract was fixed before the patch, in `architecture/ADR-008`.** `pub const Name(T) = struct { ... }` parses as `ConstDecl(name, generic_parameters, StructExpr)`. Writing the contract first is what lets the tests count for something: tests authored after a patch tend to describe whatever the patch happened to do +- **Measured**: 33 declarations of the form in 28 corpus files, every one with `struct` on the right-hand side. The old binary rejected 6 of 6 positive fixtures; the candidate accepts 6 of 6, and rejects 7 of 7 negative fixtures. `cargo test --test generic_const_decl` 16/16; `cargo test --bin t27c` 1537 passed, 0 failed, 2 ignored +- **This is a parser defect, not a corpus error**, so the 28 files are not rewritten. The owner decision is recorded in the issue rather than only in a working session -- an hourly tick had already closed this issue as *blocked, waiting on the language owner* while the decision existed, because a decision that lives in a conversation is not visible to any automated consumer +- **What this does NOT settle**, each left as `needs-language-ADR`: `Name(T)` in type *application* position (#2164) and whether `test` is a reserved word (#2165). Accepting a declaration form does not imply accepting the use form +- **`bootstrap/stage0/FROZEN_HASH` moves**, `375b2f88...` -> `315fbe1d...`. That is a GOLD-RING seal and needs explicit human approval; it is not a mechanical consequence of the patch + # NOW -- BNF: the control that measures what ternary is worth (2026-08-09) Last updated: 2026-08-09