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
84 changes: 84 additions & 0 deletions .claude/skills/ci-gates/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -5735,3 +5735,87 @@ measured, which is exactly what makes one unmeasured number dangerous.

Corrected by a follow-up commit, not an amend (see 155). **Write the number after
running the command, in the same minute, or do not write it.**

## 162. Ask the parser what it dropped instead of grepping what it printed

Yesterday's `tri discard classify` was a keyword match over printed traces: it saw
the word `forall` and called the bucket `forall`. The parser already writes down
every token it throws away, so the question could be asked of the record instead.

Two answers changed:

heuristic: forall/==> ......... 20 991
record: bdd-block-fallback ... 23 852 (78%, one channel)
brace-body ........... 4 602
top-level-resync ..... 1 894

And the head token is not the channel. `given` was the fourth-largest head at
2 453 tokens, which read like a fn-shaped defect worth thousands. Fixing that arm
recovered **43**. The other 2 410 came from braceless blocks falling back — a
different defect wearing the same first word.

**A head token says what the parser stopped ON. A channel says which recovery
threw it away. Reporting one as the other overstates every fix you plan.**

## 163. `zip` on two parallel vectors is a silent truncation

I added a channel vector beside the existing span vector, pushed at "all three"
recording sites, and zipped them. The total came out 27 tokens short of
`parse-complete`'s — two more push sites existed at a different indentation and
my search pattern had missed them.

`zip` truncated to the shorter side and reported a clean, plausible, wrong table.
Nothing failed. The only signal was a total that did not match another account of
the same thing.

if spans.len() != channels.len() { return Err(...) }

**Two vectors that must stay in lockstep need an assertion, not a `zip`.** And the
reason the gap was findable at all is that a second, independent account of the
same quantity already existed — which is the argument for keeping both.

## 164. The parser named the shape, then threw it away

// BDD-style fn: `fn name() given ... then ...` -- a keyword-style test
// spelled as a fn (linker.t27). Detect BEFORE return-type parsing.
if self.current.kind == TokenKind::Ident && self.current.lexeme == "given" {
self.skip_to_next_top_level(); // <- every clause, gone
return Ok(decl);
}

The comment cites the exact file it silently empties. Someone understood the shape
well enough to special-case it and stopped one line short of lowering it.

**A comment that names a construct beside a `skip` is a fix that was scoped and
not finished.** Grep for that pair: recognition followed by discard is a different
and better-signposted target than an unhandled shape nobody has looked at.

## 165. Same file, two flag sets, opposite verdicts

The elaboration ratchet said my change took `linker` from 4 errors to 6. I ran
iverilog on the same generated file and counted **fewer** errors than master.

The gate runs `iverilog -g2012 -DSIMULATION`; I had run bare `iverilog`. Under
the default the file is Verilog-2005, where every size cast is an error and
master's *empty task* trips "Task body with no statements" — so master looked
worse. Under `-g2012` both of those are legal, the size casts vanish, and what
remains is the two errors my change actually added.

**Copy the gate's invocation, flags included, out of its source.** A tool with
the right name and the wrong flags is a different tool, and it will happily
disagree with the gate about the same file.

## 166. If a backend refuses to lower a construct, produce that construct

A `fn` whose body is BDD clauses is a test spelled as a function. My first fix
kept it a `FnDecl` and filled the body, so `gen-verilog` — which emits RTL for
functions and deliberately does **not** lower tests — produced `\assert (…)`
inside a task and an assignment to the task's own name.

The fix was not to teach the Verilog backend about a new fn flavour. It was to
emit a `TestBlock`, because that is what the source means. Every backend already
knows whether it lowers tests.

**When recovered content lands in the wrong node kind, change the node, not the
four consumers.** The give-away is a fix that would need a matching change in
every backend: that is usually the parser choosing the wrong shape.
8 changes: 4 additions & 4 deletions .trinity/seals/Linker.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"gen_hash_c": "sha256:108d46508c8fe24bd3bca14b4b54d51c3d8827c6928c2f9be71995e446e0c857",
"gen_hash_rust": "sha256:f6ea2b804287146898f363f3c2f0f29e4185bc7a60ee3fc6c1a35e0a93143760",
"gen_hash_verilog": "sha256:78cfa653fb656438b2dff0eea3df9198dc1ade38253897016a7031868c63018d",
"gen_hash_zig": "sha256:e21e5e37dd9d05dea274649d7e956c7899d72bfe26285d9a78541993510c4d56",
"gen_hash_c": "sha256:af9eaaa325e9a0aa0f5fc783a9564b967a852322f1a63f947e02c13d65901948",
"gen_hash_rust": "sha256:e9e1f909a3f8773af1ccf6437cc65ae079945b7a57bde528760f7b5bca288037",
"gen_hash_verilog": "sha256:49b4eed7bfa531a9fa124bee2469bbab58cea99f818f0ee1609444d5a757006c",
"gen_hash_zig": "sha256:f928ec5f281b5df49d58d087284b04604e13c7dafa4c718d1bda8d7ed63a7f41",
"module": "Linker",
"ring": 12,
"sealed_at": "2026-08-28T18:19:55Z",
Expand Down
14 changes: 7 additions & 7 deletions .trinity/seals/fpga_Linker.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"gen_hash_c": "sha256:108d46508c8fe24bd3bca14b4b54d51c3d8827c6928c2f9be71995e446e0c857",
"gen_hash_rust": "sha256:f6ea2b804287146898f363f3c2f0f29e4185bc7a60ee3fc6c1a35e0a93143760",
"gen_hash_verilog": "sha256:78cfa653fb656438b2dff0eea3df9198dc1ade38253897016a7031868c63018d",
"gen_hash_zig": "sha256:e21e5e37dd9d05dea274649d7e956c7899d72bfe26285d9a78541993510c4d56",
"gen_hash_c": "sha256:af9eaaa325e9a0aa0f5fc783a9564b967a852322f1a63f947e02c13d65901948",
"gen_hash_rust": "sha256:e9e1f909a3f8773af1ccf6437cc65ae079945b7a57bde528760f7b5bca288037",
"gen_hash_verilog": "sha256:49b4eed7bfa531a9fa124bee2469bbab58cea99f818f0ee1609444d5a757006c",
"gen_hash_zig": "sha256:f928ec5f281b5df49d58d087284b04604e13c7dafa4c718d1bda8d7ed63a7f41",
"module": "Linker",
"ring": 12,
"sealed_at": "2026-08-28T18:19:55Z",
"sealed_by": "t27c-bootstrap@0.1.0",
"sealed_at": "2026-08-29T02:47:18Z",
"sealed_by": "t27c-bootstrap@0.2.0",
"spec_hash": "sha256:64a8091d1c5d8163af7ce400dee2b8ffbe713584045340e1126a0b7560b04c6e",
"spec_path": "specs/fpga/linker.t27"
}
}
95 changes: 93 additions & 2 deletions bootstrap/src/compiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1093,6 +1093,17 @@ pub struct Parser {
/// told us 55,563 tokens vanish; only reading them can say whether any of
/// it is content a theorem depends on. See T43.
dropped_spans: Vec<(u32, String)>,
/// W699: WHICH recovery threw each token away, parallel to `dropped_spans`.
///
/// The head token of a dropped run says what the parser stopped ON. It does
/// not say which of the four recovery channels did the dropping, and the two
/// are different questions: `given` is the fourth-largest head in the corpus
/// and only 43 of its 2 453 tokens came from the fn arm that names it.
dropped_channels: Vec<&'static str>,
/// Set by `restore_bdd_fallback` for the duration of its resync, so the
/// tokens a WHOLE-BLOCK fallback discards are distinguishable from an
/// ordinary top-level resync. Both call the same function.
in_bdd_fallback: bool,
}

#[derive(Clone)]
Expand Down Expand Up @@ -1155,6 +1166,8 @@ impl Parser {
bdd_role_preset: false,
bdd_first_col_preset: None,
dropped_spans: Vec::new(),
dropped_channels: Vec::new(),
in_bdd_fallback: false,
}
}

Expand Down Expand Up @@ -1263,6 +1276,7 @@ impl Parser {
if self.dropped_spans.len() < 20000 {
self.dropped_spans
.push((self.current.line as u32, self.current.lexeme.clone()));
self.dropped_channels.push("brace-body");
}
self.advance();
}
Expand Down Expand Up @@ -1330,6 +1344,7 @@ impl Parser {
if self.dropped_spans.len() < 20000 {
self.dropped_spans
.push((self.current.line as u32, self.current.lexeme.clone()));
self.dropped_channels.push("clause-junk");
}
if self.current.kind == TokenKind::LBrace {
self.advance();
Expand Down Expand Up @@ -1425,6 +1440,14 @@ impl Parser {
if self.dropped_spans.len() < 20000 {
self.dropped_spans
.push((self.current.line as u32, self.current.lexeme.clone()));
// W699: `restore_bdd_fallback` resyncs through this same
// function, and "a whole braceless block fell back" is a
// different finding from "a top-level item was skipped".
self.dropped_channels.push(if self.in_bdd_fallback {
"bdd-block-fallback"
} else {
"top-level-resync"
});
}
self.advance();
}
Expand Down Expand Up @@ -1551,6 +1574,7 @@ impl Parser {
if self.dropped_spans.len() < 20000 {
self.dropped_spans
.push((self.current.line as u32, self.current.lexeme.clone()));
self.dropped_channels.push("stray-brace");
}
self.advance();
self.parse_module_body(&mut module)?;
Expand Down Expand Up @@ -2993,8 +3017,36 @@ impl Parser {
// spelled as a fn (linker.t27). Detect BEFORE return-type parsing,
// which would otherwise consume `given` as an identifier return type.
if self.current.kind == TokenKind::Ident && self.current.lexeme == "given" {
self.skip_to_next_top_level();
return Ok(decl);
// W699 rung 5: this arm RECOGNISED the shape by name and then threw
// it away. `skip_to_next_top_level()` discarded every clause under
// such a fn -- 2 453 tokens across 19 specs, and `given` is the
// fourth-largest head token in the whole corpus discard.
//
// The shared clause parser already serves `test`, `invariant` and
// `bench`; a fn spelled as a test is the same body. Detection is
// unchanged -- same token, same lexeme -- only the action is.
//
// The column anchor is what the bench path (W905) had to set for the
// same reason: continuation statements need an anchor that the head
// never established, and without it the statement arms stay dark.
// ... and it is lowered as a TEST, not as a function.
//
// The first attempt kept the FnDecl and filled its body. Every
// backend then treated it as a real function: `gen-verilog` emits
// synthesizable RTL for functions and deliberately does NOT lower
// tests, so the recovered clauses became `\assert (...)` inside a
// task -- "Enable of unknown task ``assert''" -- and the elaboration
// ratchet caught it, linker 4 -> 6. Correctly: a task body cannot
// assert, and a test body must not become one.
//
// `fn name() given ... then ...` IS a test. Emitting a TestBlock
// teaches no backend anything new: each already knows whether it
// lowers tests, and each now does the right thing with this one.
let mut block = Node::new(NodeKind::TestBlock);
block.name = decl.name.clone();
self.bdd_first_col_preset = Some(self.current.col);
self.parse_bdd_clauses(&mut block);
return Ok(block);
}

// Optional arrow for return type: -> Type
Expand Down Expand Up @@ -3296,6 +3348,7 @@ impl Parser {
if self.dropped_spans.len() < 20000 {
self.dropped_spans
.push((self.current.line as u32, self.current.lexeme.clone()));
self.dropped_channels.push("stmt-recovery");
}
}
match self.current.kind {
Expand Down Expand Up @@ -6545,7 +6598,13 @@ impl Parser {
}
block.children.truncate(start_children);
self.restore_state(entry);
// W699: nested fallbacks are possible, so save and restore rather than
// clearing -- an inner block finishing must not tell the outer one's
// remaining tokens they came from a plain resync.
let outer = self.in_bdd_fallback;
self.in_bdd_fallback = true;
self.skip_to_next_top_level();
self.in_bdd_fallback = outer;
}

fn parse_invariant_block(&mut self) -> Result<Node, String> {
Expand Down Expand Up @@ -19342,6 +19401,38 @@ impl Compiler {
Ok((ast, parser.dropped_top_level_tokens()))
}

/// W699: the discarded tokens with the recovery that threw each away, as
/// `(line, lexeme, channel)`.
///
/// The channel vector is pushed in lockstep with the spans. If the two
/// lengths ever disagree this REFUSES rather than zipping to the shorter --
/// see the note at the check.
pub fn parse_ast_dropped_records(source: &str) -> Result<Vec<(u32, String, &'static str)>, String> {
let lexer = Lexer::new(source);
let mut parser = Parser::new(lexer);
let _ = parser.parse()?;
// W699: `zip` truncates to the shorter side, and that is exactly how a
// missed push site hides. Two sites WERE missed on the first pass -- 27
// tokens across 27 specs -- and the only reason it surfaced was a total
// that did not match `parse-complete`'s. Refuse instead of truncating.
if parser.dropped_spans.len() != parser.dropped_channels.len() {
return Err(format!(
"drop accounting is inconsistent: {} span(s) but {} channel(s). \
A recovery site records a span without naming its channel; find \
it rather than reading the shorter list.",
parser.dropped_spans.len(),
parser.dropped_channels.len()
));
}
Ok(parser
.dropped_spans
.iter()
.cloned()
.zip(parser.dropped_channels.iter().copied())
.map(|((l, x), c)| (l, x, c))
.collect())
}

/// W634: the discarded tokens themselves, as `(line, lexeme)`.
pub fn parse_ast_dropped_spans(source: &str) -> Result<Vec<(u32, String)>, String> {
let lexer = Lexer::new(source);
Expand Down
Loading
Loading