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
72 changes: 72 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,78 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

---

## [0.2.0] - 2026-08-27

t27c only. Seven defects, five of them a green exit that was not a result.

### Fixed

- **`StmtForRange` was unreachable, so two backends could not lower `for`.**
`parse_for_range` parses its start bound with the full expression grammar,
and that grammar carries `..` as a binary operator (for slices), so
`for i in 0..8` came back as one `ExprBinary` and every range loop was built
as the collection form. Measured on the previous release: **0** `StmtForRange`
nodes across 746 tracked specs against 383 `StmtFor`, which made
`gen_c_for_range_stmt` and `gen_verilog_for_range_stmt` dead code.
- `gen-c` emitted no loop header at all — a comment and a bare block, so the
body lowered exactly once. 391 sites in 48 specs.
- `gen-verilog` emitted the range as the bound: `for (i = 0; i < (0 .. 8); …)`,
which iverilog rejects. 32 sites in 14 specs.
- Fixed with a `no_range` suppression beside the existing `no_struct_literal`.
Verified by running: C now prints 8 for a loop over `0..8`; iverilog accepts
the Verilog; the seven `for_range` unit tests that already encoded this
lowering and were red now pass.
- **`gen-rust` emitted an empty `match` for every `switch`.** The arm loop
tested for a node kind the parser never builds, so `match a { }` shipped with
exit code 0 while `gen-c` and `gen-verilog` lowered the same construct.
Patterns are qualified from the scrutinee's declared type.
- **`gen-rust` dropped the body of every `for` loop** and renamed the induction
variable to the literal string `body`, reading the capture and the body from
the wrong children.
- **`health` was red, and the broken thing was the compiler's own embedded
self-check spec** — its invariant used a `forall` form the parser rejects, so
`t27c health` failed at parse and never reached typecheck or any backend.
Rewritten to a form that parses *and lowers*; all six stages now report.
- **`ci` printed `CI: PASSED` and exited 0 over a repository root that did not
exist.** Now refuses a root holding neither `specs/` nor `compiler/`, and
reports `CI: NO INPUT` with exit 2 when the tree exists but holds no `.t27`.
- **`battery --dir` ignored its argument.** `repo_root.join(dir)` replaces the
base when `dir` is absolute and the directory read swallowed the failure, so
the fallback ran this repository's own gates and reported on a tree the caller
had not named. Now refuses a non-directory, prints the oracle and gate counts
separately, and refuses when the oracle count is zero.

### Added

- **The inclusive range `a..=b`.** The lexer emits `..` and a separate `=`;
lowered to the exclusive range over `b + 1`, so no backend needs a new
operator. This unblocked `specs/math/constants.t27`, which 259 of 746 specs
import.
- **Paren-less `if` in expression position.** The statement parser had accepted
it since W578; the expression parser still required parentheses and failed
with the identical diagnostic that parser's own comment says was fixed.
- **The parenthesised range for, `for (i in a..b)`.** `if (…)` and `while (…)`
already accepted the form. Checkpointed so Zig's `for (xs) |x|` is unaffected.
- **`--version` / `-V`.** The `version` subcommand existed; the flag did not.

### Measured

| | 0.1.0 | 0.2.0 |
|---|---|---|
| specs that parse (746 tracked) | 603 | **620** |
| suite `parse` failures | 110 | **92** |
| t27c unit tests | 1622 passed / 13 failed | **1629 passed / 6 failed** |
| `gen-c` specs with a dropped loop header | 48 | **37** |
| `StmtForRange` nodes in the corpus | 0 | reachable |

Zero parse regressions at every step. The 37 remaining dropped loops are the
collection form `for x in xs`, which `gen-c` does not lower either — a separate
defect. `parse-no-discard` and `seal-verify` both rose, mechanically: a spec
that could not parse at all now parses and reveals that it discards tokens, and
changing an emitter's output makes stored seals stop matching.

---

## [0.1.0] - 2026-04-07

### Added
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion bootstrap/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "t27c"
version = "0.1.0"
version = "0.2.0"
edition = "2021"
description = "T27 Bootstrap Compiler for Trinity S³AI Framework"
license = "MIT"
Expand Down
82 changes: 79 additions & 3 deletions bootstrap/src/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1059,6 +1059,16 @@ pub struct Parser {
/// the branch body, not a struct literal. Rust has the same ambiguity and
/// resolves it the same way (W578).
no_struct_literal: u32,
/// Suppress `..` as a binary operator while a RANGE BOUND is being parsed.
///
/// `parse_for_range` parses its start bound with the full expression
/// grammar, and that grammar carries `..` in the comparison chain (for
/// slices). So `for i in 0..8` came back as ONE ExprBinary, the
/// `current.kind != DotDot` test below it never fired, and every range
/// loop was built as the COLLECTION form. `StmtForRange` was unreachable:
/// 0 nodes across 746 tracked specs against 383 `StmtFor`, and with it
/// `gen_c_for_range_stmt` and `gen_verilog_for_range_stmt` were dead code.
no_range: u32,
/// W633: tokens DISCARDED by top-level drop-recovery. The parser resyncs
/// past an unrecognised declaration and reaches EOF, so
/// `parse_ast_strict`'s "did we reach EOF?" check reports "consumed all"
Expand Down Expand Up @@ -1131,6 +1141,7 @@ impl Parser {
peek: second,
pending_pragma: String::new(),
no_struct_literal: 0,
no_range: 0,
dropped_top_level_tokens: 0,
hoisted_fns: Vec::new(),
in_bdd_clause_value: false,
Expand Down Expand Up @@ -3914,7 +3925,28 @@ impl Parser {
let ident = self.current.lexeme.clone();
self.advance(); // consume ident
self.advance(); // consume 'in'
return self.parse_for_range(ident);
return self.parse_for_range(ident, false);
}

// PARENTHESISED range for: `for (i in a..b) { body }`. `if (...)` and
// `while (...)` both accept the parenthesised form; `for` did not, and
// the diagnostic landed on the `(` as "Expected LBrace, got LParen" --
// which reads as a missing body rather than a rejected spelling.
//
// Checkpointed rather than looked ahead: the parser holds only
// `current` and `peek`, and Zig's capture form `for (xs) |x| { }`
// opens with the same `(`. On anything but IDENT + `in` the state is
// restored and that branch runs unchanged.
if self.current.kind == TokenKind::LParen {
let checkpoint = self.save_state();
self.advance(); // consume '('
if self.current.kind == TokenKind::Ident && self.peek.kind == TokenKind::KwIn {
let ident = self.current.lexeme.clone();
self.advance(); // consume ident
self.advance(); // consume 'in'
return self.parse_for_range(ident, true);
}
self.restore_state(checkpoint);
}

let mut for_node = Node::new(NodeKind::StmtFor);
Expand Down Expand Up @@ -3976,7 +4008,10 @@ impl Parser {
}

/// Parse range for body: start_expr .. end_expr { body }
fn parse_for_range(&mut self, var_name: String) -> Result<Node, String> {
/// `paren` -- the caller consumed a `(` before the loop variable, so the
/// matching `)` sits immediately before the body brace and must be eaten
/// on BOTH paths out of this function.
fn parse_for_range(&mut self, var_name: String, paren: bool) -> Result<Node, String> {
let mut node = Node::new(NodeKind::StmtForRange);
node.name = var_name;

Expand All @@ -3988,7 +4023,9 @@ impl Parser {
// `for i in 0..max_iterations {` ate the body as a literal and the
// whole fn fell. Same suppression as if/while conditions.
self.no_struct_literal += 1;
self.no_range += 1;
let start = self.parse_expr();
self.no_range -= 1;
self.no_struct_literal -= 1;
let start = start?;

Expand All @@ -4005,6 +4042,9 @@ impl Parser {
// Captures live in `params`, exactly as the parenthesised
// `for (xs) |x| { ... }` form stores them.
coll.params.push((node.name.clone(), String::new()));
if paren {
self.expect(TokenKind::RParen)?;
}
self.expect(TokenKind::LBrace)?;
let mut body_block = Node::new(NodeKind::Module);
body_block.name = "body".to_string();
Expand All @@ -4028,9 +4068,41 @@ impl Parser {

self.expect(TokenKind::DotDot)?;

let end = self.parse_range_bound()?;
// `for i in a..=b`. With `no_range` active the chain no longer eats the
// `..`, so the inclusive `=` arrives here. Lowered to the exclusive
// range over `b + 1`: every backend lowers `..` already and none would
// know `..=`.
let inclusive = self.current.kind == TokenKind::Equals;
if inclusive {
self.advance();
}
// The END bound is a full expression: `for i in 0..len(s)` is real and
// `parse_range_bound` is deliberately restricted (it stops at `db` in
// `db.facts`). Both suppressions apply -- `no_range` so a following
// `..` is left alone, `no_struct_literal` because the `{` after the
// bound opens the loop BODY, not a struct literal.
self.no_struct_literal += 1;
self.no_range += 1;
let end = self.parse_expr();
self.no_range -= 1;
self.no_struct_literal -= 1;
let end = end?;
let end = if inclusive {
let mut plus = Node::new(NodeKind::ExprBinary);
plus.extra_op = "+".to_string();
plus.children.push(end);
let mut one = Node::new(NodeKind::ExprLiteral);
one.value = "1".to_string();
plus.children.push(one);
plus
} else {
end
};
node.children.push(end);

if paren {
self.expect(TokenKind::RParen)?;
}
self.expect(TokenKind::LBrace)?;
let mut body_block = Node::new(NodeKind::Module);
body_block.name = "body".to_string();
Expand Down Expand Up @@ -4208,6 +4280,10 @@ impl Parser {
{
break;
}
// A range BOUND must not swallow its own `..`; see `no_range`.
if self.current.kind == TokenKind::DotDot && self.no_range > 0 {
break;
}
let op = self.current.lexeme.clone();
self.advance();
// `a..=b` -- the INCLUSIVE range. The lexer emits `..` and then a
Expand Down
24 changes: 23 additions & 1 deletion bootstrap/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ use std::path::{Path, PathBuf};

#[derive(Parser)]
#[command(name = "t27c")]
// `t27c version` existed; `--version` and `-V` did not, so the flag every
// other CLI answers returned "unexpected argument". One attribute.
#[command(version)]
#[command(about = "T27 Bootstrap Compiler for Trinity S³AI Framework", long_about = None)]
struct Cli {
#[command(subcommand)]
Expand Down Expand Up @@ -9639,7 +9642,8 @@ test add_basic {
assert add(0, 0) == 0
}
invariant add_commutative {
forall a: u32, b: u32 . add(a, b) == add(b, a)
assert add(1, 2) == add(2, 1)
assert add(7, 0) == add(0, 7)
}
"#;
let mut errors = Vec::new();
Expand Down Expand Up @@ -9852,6 +9856,17 @@ fn run_ci(repo_root: &str) -> anyhow::Result<()> {
let mut files_checked = 0u32;

let dirs = vec![format!("{}/specs", repo_root), format!("{}/compiler", repo_root)];
// Refuse a root that holds neither tree. Without this, `ci --repo-root
// /tmp/does-not-exist` walked nothing, found nothing, and printed
// "CI: PASSED" with exit 0 -- a green verdict over an input that was
// never read, which is the one failure mode this repository names most.
if dirs.iter().all(|d| !std::path::Path::new(d).exists()) {
anyhow::bail!(
"no specs/ or compiler/ under {} -- nothing to check.\n\
A green CI verdict over a tree that does not exist is not a result.",
repo_root
);
}
for dir in &dirs {
if !std::path::Path::new(dir).exists() { continue; }
let mut stack = vec![std::path::PathBuf::from(dir)];
Expand Down Expand Up @@ -9894,6 +9909,13 @@ fn run_ci(repo_root: &str) -> anyhow::Result<()> {
println!("Files checked: {}", files_checked);
println!("Total issues: {}", total_failures);
println!("Duration: {:.2}s", elapsed.as_secs_f64());
// A scan that read zero files is not a pass. The directories existed --
// that is checked above -- so an empty walk means the tree holds no .t27
// at all, and saying PASSED there is the same lie one level down.
if files_checked == 0 {
println!("CI: NO INPUT -- the tree exists but holds no .t27 file");
std::process::exit(2);
}
if total_failures == 0 {
println!("CI: PASSED");
} else {
Expand Down
38 changes: 38 additions & 0 deletions bootstrap/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2199,6 +2199,23 @@ pub fn run_battery(repo_root: &Path, dir: String) -> anyhow::Result<()> {
let mut total = 0usize;
let mut failed = Vec::new();

// `--dir` must name a directory that exists. `repo_root.join(dir)`
// REPLACES the base when `dir` is absolute, and the read below is a
// `if let Ok(..)` that swallows the failure -- so `battery --dir
// /tmp/does-not-exist` found no oracles there, fell through to
// `repo_root/tools`, ran this repository's own 13 gates, and reported on a
// tree the caller never asked about.
if !doc.is_dir() {
anyhow::bail!(
"--dir {} is not a directory.\n\
Refusing rather than falling back to {}/tools: a battery that \
silently audits a different tree than the one you named is worse \
than no battery.",
doc.display(),
repo_root.display()
);
}

let mut scripts: Vec<std::path::PathBuf> = Vec::new();
if let Ok(rd) = std::fs::read_dir(&doc) {
for e in rd.filter_map(|e| e.ok()) {
Expand All @@ -2221,10 +2238,31 @@ pub fn run_battery(repo_root: &Path, dir: String) -> anyhow::Result<()> {
}
}
}
let n_oracles = scripts
.iter()
.filter(|p| {
let n = p.file_name().unwrap_or_default().to_string_lossy().to_string();
n.starts_with("recompute_") || n.starts_with("adjudicate_")
})
.count();
let n_gates = scripts.len() - n_oracles;
scripts.sort();
if scripts.is_empty() {
anyhow::bail!("no recompute_*/adjudicate_*/check_* scripts under {} or tools/", doc.display());
}
// Both counts, always. The union being non-empty says nothing about the
// directory you named: `tools/` alone supplies every `check_*` in this
// repository, so a run with zero oracles is a run about somewhere else.
println!("battery: {} oracle(s) under {}, {} gate(s) from tools/",
n_oracles, doc.display(), n_gates);
if n_oracles == 0 {
anyhow::bail!(
"no recompute_*/adjudicate_* oracle under {} -- the {} gate(s) below \
come from tools/ and say nothing about that directory.",
doc.display(),
n_gates
);
}

for s in &scripts {
total += 1;
Expand Down
2 changes: 1 addition & 1 deletion bootstrap/stage0/FROZEN_HASH
Original file line number Diff line number Diff line change
@@ -1 +1 @@
ebf8407b80b6d32bb2ff59549a8203d6a85deb15f289d76af8a433cb49a104b7
eb54f8a9b83f5f1a7d0d2a4b216deb5f99f4d110471a6c724072df422b89fd2a
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# NOW -- t27c 0.2.0: seven defects, five of them a green exit (2026-08-27)

## t27c 0.2.0: seven defects, five of them a green exit (Refs #2161)

- Refs #2161. StmtForRange was UNREACHABLE: parse_for_range parses its start bound with the full expression grammar, which carries `..` as a binary operator for slices, so every range loop was built as the collection form. 0 StmtForRange nodes across 746 specs against 383 StmtFor -- gen_c_for_range_stmt and gen_verilog_for_range_stmt had never run
- Cost per backend: gen-c emitted NO loop header at all (391 sites, 48 specs) so the body lowered exactly once; gen-verilog emitted the range as the bound, `for (i = 0; i < (0 .. 8); ...)`, which iverilog rejects (32 sites, 14 specs). Fixed with a `no_range` suppression beside the existing no_struct_literal
- The oracle was already in the tree and red: seven for_range unit tests encode exactly this lowering and were failing on master. 1622 passed/13 failed -> 1629 passed/6 failed, and the seven that turned green are exactly those
- My first version of the fix regressed one spec: `for i in 0..len(s)` has a CALL as its end bound and parse_range_bound is deliberately restricted. The end bound now uses the full grammar under both suppressions
- Three more green-exit-that-is-not-a-result, all in t27c own CLI: `health` was red because the compiler embedded self-check spec used a forall form its parser rejects; `ci --repo-root <nonexistent>` printed CI: PASSED and exited 0; `battery --dir` ignored its argument entirely and audited THIS repository instead, because repo_root.join(dir) replaces the base on an absolute path and the read failure was swallowed
- Plus the parenthesised `for (i in a..b)` -- the only cluster in this corpus whose fix-yield equals its size, 5 specs of 5 -- and `--version`, which did not exist though `t27c version` did
- Measured 0.1.0 -> 0.2.0: specs that parse 603 -> 620, suite parse failures 110 -> 92, gen-c dropped loop headers 48 -> 37, zero parse regressions at every step. parse-no-discard and seal-verify both ROSE, mechanically: a spec that could not parse now parses and reveals it discards tokens, and changed emitter output makes stored seals stop matching
Loading
Loading