Skip to content

Further fixes and advancements in RT process simulations - #429

Merged
soronpo merged 29 commits into
mainfrom
training
Aug 1, 2026
Merged

Further fixes and advancements in RT process simulations#429
soronpo merged 29 commits into
mainfrom
training

Conversation

@soronpo

@soronpo soronpo commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

No description provided.

Oron Port and others added 29 commits July 30, 2026 13:38
- implement `unary_-` for Bits: implicit conversion to SInt[W+1] via
  `.uint.signed` before negation, matching the existing UInt behavior;
  covered in DFDecimalSpec (assignment codegen + width-overflow error)
- type-system reference: document that bitwise NOT `~` also applies to
  Bits/UInt vectors and preserves the argument type (incl. Verilog/VHDL
  mapping notes), and add a dedicated unary negation subsection with the
  per-type result rules (SInt/Int/Double preserved, UInt[W]/Bits[W] to
  SInt[W+1])
- from-verilog guide: new admonition contrasting Verilog's context-width
  wrap-around negation with DFHDL's exact SInt[W+1] result, including the
  same-width wrap-around form `(-x.sint).bits` and the code-smell note
  about sign-bit preservation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- `lsbitsAt(baseIdx, selWidth)` selects selWidth bits with the LSB
  anchored at baseIdx (Verilog `[baseIdx +: selWidth]`); `msbitsAt`
  anchors the MSB at baseIdx (Verilog `[baseIdx -: selWidth]`); both
  generalize msbits/lsbits and desugar to the existing ApplyRange
- available on Bits (Bits result) and on UInt/SInt (UInt result,
  matching the unsigned-slice rule); assignability is preserved so a
  part-select can be an assignment target, like a `(hi, lo)` slice
- bounds are checked at compile time for literal arguments and at
  elaboration for parametric ones (Arg.Width on the width, BitIndex on
  both computed endpoints)
- DFBitsSpec coverage: all three types, parametric base (folded print),
  assignment target, out-of-range and zero-width errors
- docs: part-select bullet + examples in the type-system reference and
  a direct `-:`/`+:` mapping in the from-verilog guide

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ardware generation

Rework the FullAdderN docExample to use classic hardware-generation style (a
`for`-loop ripple of FullAdder1 instances with a sibling carry chain) instead of
a `List`, and update the generated Verilog/VHDL references accordingly.

This surfaced two DFacsimile simulation gaps, now fixed:

- The scope walk read connection sources eagerly, so a `<>` whose driver appears
  later in source order failed with "reading a value before it is driven".
  Connections are continuous (order-free), so forward-read connection sinks are
  now stood in with MOV placeholders patched at scope finalize. Whole-driven
  sinks get one whole-value placeholder; partially-driven sinks (bit/range
  connections, e.g. a ripple carry) get independent per-bit placeholders. A
  single whole placeholder for a partial sink coalesces independent bits into one
  node and turns a bit-acyclic ripple into a false combinational cycle.

- FullAdderNSimSpec dropped the dead internal-port assertions and now drives and
  observes only the public ports.

Adds OrderFreeConnectSimSpec (hierarchy-free: whole + partial forward references
and a multi-hop forward chain) covering the resolution on both simulation tiers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two pre-existing gaps left FALL_THROUGH a no-op for some loop forms:

- SimplifyRTOps dropped the loop's tags when rewriting a for loop into a
  while loop, so a FALL_THROUGH for loop lost its marker. Carry the for
  block's tags onto the generated while block.
- DropRTWaits only emitted the `fallThrough` sub-step for while loops with a
  non-empty body; an empty-bodied FALL_THROUGH while loop silently ignored the
  tag. Emit the same `fallThrough` sub-step in the empty-body branch.

With both fixes a FALL_THROUGH loop of any form lowers to the fall-through
cascade that skips it with zero cycles when its guard is false on entry.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Implements the RT-process step hooks and fall-through loops directly in the
DFacsimile simulator (M3), verified cycle-exact against the dropRTProcess
FSM-lowering oracle on both tiers:

- onEntry / onExit: run at a step's non-self transition edges (skipped on a
  self-transition), matching the FSM lowering's static source-vs-target gate.
- fallThrough: a same-cycle conditional cascade along declaration order, with
  the circular-fall-through guard, running each traversed step's onEntry.
- FALL_THROUGH while loops (empty and non-empty body): a zero-cycle skip to the
  following park when the guard is false on entry.

Hook step blocks (onEntry/onExit/fallThrough) are no longer time constructs and
are skipped by the ordered body walk; they are emitted at the transition edges.

Two cases are cleanly rejected rather than mis-simulated:
- FALL_THROUGH for loops, whose FSM lowering reads the pre-reset (stale)
  iterator in the fall-through guard (hardware skips the loop every other run).
- onEntry on the first step, which is process-prologue content that folds into
  the reset state and is not yet integrated with the boot machinery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A reference for continuing to improve DFacsimile: the architecture (Netlist /
WideOps / two tiers / ProcLowering FSM model), the fidelity contract (match the
FSM-lowering stages bug-for-bug, verified by the dropRTProcess lockstep oracle),
the testing + divergence-debugging methodology, and the gotchas banked this
milestone. Registered in CLAUDE.md's Claude Instructions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The process prologue is the leading statements together with the first step's
onEntry, so DFacsimile now vets both in the bootstrap gate (mirroring
DropRTWaits Rule 6) and folds both into the time-zero state, leading statements
first. The onEntry folds only when its step is the process's first state: with
a leading wait or loop the FSM lowering keys the generated initial block on
that construct instead and leaves the hook on its transition edge.

Matching the lowering required moving hook emission from the goto site to the
transition's landing point. The lowering plants a goto's hooks at the end of
the flattened state body, i.e. after FlattenStepBlocks relocated the inter-step
trailing statements and cloned the prologue there, so onExit is now emitted
once per execution path right before the state write and onEntry inside the
state landing itself. As a consequence a sequential entry into a nested step
fires the parent's onExit and the child's onEntry, which is what the flattened
form does.

Two further fidelity gaps surfaced and are fixed here:

- A hook-carrying step, or one whose dispatch's first time-consuming action is
  hook-carrying, is never a FirstStepFusion candidate; it is now forced to keep
  a state of its own, so its hooks have a real FSM edge to land on.
- The fall-through cascade stops after re-entering the step the transition
  left, not before it (the lowering's guard sits inside handleNextStep, after
  that step's onEntry and state write). It now also emits only the next state's
  onEntry and state write, skipping the traversed state's own body, and re-runs
  the prologue when it passes the last state.

A cascade past the last state of a process with a fused entry state is
unsupported rather than approximated. FALL_THROUGH for loops remain the one
deferred M3 case (the stale-iterator lowering bug).

Verified with eight new lockstep DUTs against the dropRTProcess oracle on both
tiers, each validated by temporarily breaking the code it targets; the SERV,
protocol-engine and SHA-farm benchmarks produce bit-identical architectural
signatures before and after.

The user-guide correction: the prologue's "does not run on an explicit jump to
the first step" holds for the leading statements but not for the onEntry, which
fires on every non-self entry into the first step.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A `fallThrough` cascade advanced to the declaration-order successor in the flat
state list (`nextBlocks`), which is not where the skipped step goes. A loop step
whose body consumes cycles is followed in that list by its own body's first
state, so skipping the loop entered the body of the very loop being skipped:

    case State.S_0 =>
      state.din := State.S_1
      if (!go) state.din := State.S_1_0   // the loop body's wait, not the exit

DropRTProcess now cascades to the step's default exit -- the target of the last
Goto on its dispatch path, hook bodies excluded -- which is where control lands
when the step neither stays put nor takes an earlier branch. This is what Rule 4
always claimed ("the one S itself eventually transitions to via its own Goto");
declaration order was a proxy that only coincides when the step holds no states
of its own and its exit is the next one declared.

Two consequences:

- Rule 5 needs a second stop condition. Declaration order is a single ring, so a
  cascade always came back to its origin; exit gotos form an arbitrary graph, so
  a chain can close on itself without reaching it. It now also stops on a step
  the same chain already passed through. This is load-bearing, not defensive:
  the circular fall-through case recurses forever without it.
- The wrap-around test is tighter. The prologue re-ran whenever the cascade
  skipped the last state; it now also requires the cascade to land on the first,
  since the last state's exit is no longer necessarily the first.

DFacsimile mirrors the rule in `cascadeFrom` (fidelity, locked decision 11),
resolving only gotos that name their target -- `NextStep`/`ThisStep` keep
falling back to the sequential park walk, which is how FlattenStepBlocks
resolves them.

The circular fall-through golden changes for the better: those steps end in
explicit S0/S1/S2 gotos, and the cascade used to divert through the generated
bootstrap state instead of following them. It now matches the plain transitions.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`FirstStep` resolved to whichever step came first in the flat state list, which
is the synthesized bootstrap when the prologue is not initial-convertible. So it
re-ran the prologue and paid the bootstrap's cycle, while naming that same step
did neither -- even though the docs equate the two forms, and the convertible
case already behaved that way. The meaning of `FirstStep` was decided by whether
constant-folding happened to apply:

    z.din := z + 1                        z.din := 7
    ...                                   ...
    if (x) state.din := State.S_0         if (x) state.din := State.Accum
    else   state.din := State.Accum       else   z.din := d"8'7"
                                                 state.din := State.Accum

`FirstStep` now targets the process's own first step, whatever construct yielded
it -- a `def`, a wait, a loop, named or not. The sequential wrap-around still
goes through the bootstrap, which is what re-runs the prologue.

The obvious implementation -- tag the synthesized step, skip tagged steps --
breaks the printability invariant: a stage's printed output must be a complete
contract, so that printing it, re-elaborating it and continuing the pipeline
matches passing the IR along. CombinationalTag and FallThroughTag are safe
because they print (COMB_LOOP:/FALL_THROUGH:); a "this step was synthesized" tag
has no printed form and nothing regenerates it, and DropRTWaits still emits
relative gotos, so the loss is observable. It is not recoverable structurally
either: the printed bootstrap is byte-identical to a user-written first step
followed by an inter-step statement.

So bootstrap synthesis moves out of DropRTWaits into FlattenStepBlocks, where
every goto becomes explicit in the same run and both targets are materialized in
the IR. FlattenStepBlocks gains a pre-phase that synthesizes the step and carries
its identity by value across its own phases, as Phase 4's fusion candidates
already do. DropRTWaits Rule 6 now only documents that the prologue is left in
place, and why the decision cannot live there.

The step is named S_boot: DropRTWaits owns the S_<n> counter and has already
handed out S_0. The forkJoin HDL goldens shift accordingly, and their state enums
no longer deduplicate across the three processes -- previously a coincidence of
identical generated names, not a property. Verified rename-only by normalizing
both outputs through the rename map and diffing: every transition, reset target
and condition is unchanged.

Behaviour is unchanged throughout: every lockstep test passed on the first run
after the move, including ShareGateProc, which pins the trailing-share case where
the bootstrap must stay on the wrap path.

The printability invariant is now the third required stage property in the
stage-authoring guide.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
FirstStepFusion refused any hook-carrying step, which made FALL_THROUGH on a
loop whose body consumes cycles a pessimization rather than a no-op. The marker
kept the loop head as a state -- 4 states, 2 cycles per iteration -- where the
same loop without it fused away to 3 states and 1 cycle, and already skipped
with zero cycles when the guard was false on entry:

    case State.S_0 =>                       case State.S_0 =>
      state.din := State.S_1                  if (go)
      if (!go) state.din := State.S_2           y.din := y + d"8'1"
    case State.S_1 =>                           state.din := State.S_1_0
      if (go)                                 else state.din := State.S_2
        y.din := y + d"8'1"
        state.din := State.S_1_0
      else state.din := State.S_2

A fallThrough holding nothing but its condition no longer blocks fusion. A
fused step consumes no cycle at all, which is strictly more than the
conditional zero-cycle skip the hook asks for. onEntry/onExit still block, and
so does a fallThrough carrying statements of its own: those must land on a real
FSM edge, and a fused step has none.

The condition is materialized at every site as the inlined dispatch's first
decision, `if (cond) <default exit> else <dispatch>`, and being part of the
dispatch it is forwarded like the step's own guards. When it is the negation of
the dispatch's own leading guard over an else path that is nothing but the
default exit, it is dropped outright -- so a FALL_THROUGH loop lowers to output
byte-identical to the unmarked loop, which is what the right-hand column above
is. That is exactly the shape DropRTWaits gives such a loop; the guard tree is
compared with =~ because the hook holds a clone of it, so identity would only
match a bare port.

The process's first step is excluded on both sides: it survives as the reset
bootstrap state, where the hook would have no edge left to run on.

DFacsimile had no such restriction for loops, so this was a live
simulator/hardware divergence and not only an inefficiency -- the new
FallThroughWaitLoopProc lockstep fails against the pre-change lowering. It now
mirrors the rule in hookBlocked, enterStep's fused path, and fusedFallThroughExit,
which resolves the nested-form equivalent of the flat default exit (a trailing
NextStep in a step that owns nested steps enters the first of them, as
FlattenStepBlocks Rule 4 makes of it). Only the user-written-step case needed
new simulator code: a fallThrough over a nested step used to keep a state.

DropRTProcessSpec's cascade-target regression test needed a new DUT, since its
old one now fuses. Its guard register is assigned conditionally on the loop-back
edge, which keeps the loop step a real state -- and that is what leaves the
fallThrough an edge hook, which is what the test is there to pin.

Found while testing and left alone, being pre-existing and unchanged here: a
fallThrough cascade that performs the forever wrap-around re-runs the prologue
only when its step is the declaration-order last state, which a FALL_THROUGH
loop ending a process never is. The materialized hook is faithful to the
unfused lowering, bug included. Fixing it is not local -- the rotation places
the prologue clone inside the step body, which a fall-through is defined to
skip.

Each piece verified by reverting it and watching the tests fail: the stage rule
takes down 5 test instances, the subsumption check the byte-identical golden,
and the simulator change FallThroughFusedStepProc on both tiers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A fallThrough cascade that performs the forever wrap-around re-ran the prologue
only when its step was the declaration-order last state -- the same
declaration-order-vs-actual-exit mistake as 140bb17. A FALL_THROUGH loop
ending a process is never that state, since its own body's state follows it, so
skipping the loop dropped the wrap-around re-initialization:

    def S_0: Step =
      if (!go) S_0                 // skip: no cnt.din := 0
      else
        if (go) { cnt.din := cnt + d"8'1"; S_1_0 }
        else { cnt.din := d"8'0"; S_0 }

The fix is not to teach the cascade to recognize the wrap. Look at the shape:
`!go` and `go` are complements of one value in one cycle, so the guard-false
path can never be taken -- it is dead code. And that path is exactly where
FlattenStepBlocks Rule 3 relocates whatever follows the construct: trailing
statements, and here the rotation's prologue clone. Materializing a hook whose
condition is the negation of the dispatch's leading guard does not merely skip
that work, it makes it unreachable.

So dropping the hook is the only reading under which the guard-false path means
anything, and it is what the loop docs already promise -- "falls through without
consuming any cycles ..., continuing at whatever follows the loop".
fallThroughSubsumed now allows statements on that path; it still refuses a
further goto or step there, which would mean the path is not the single edge the
hook names.

DFacsimile needs no mirror: enterLoop's fused skip is an ordinary emitCont, so
it was the side that was already right, and the new FallThroughWrapLoopProc
lockstep is what caught this. The gap survives only where the loop keeps a state
-- a park body, or a fusion fallback -- which is the case gotcha 3 of the
dfacsimile skill documents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`r.din` is now readable, yielding the register's pending next-cycle value: the
latest value assigned to it so far in the current cycle body, or the register
output when nothing has been assigned yet.

Frontend: `REG_DIN` holds the register value and builds an `Alias.RegDIN` read
marker lazily, so `r.din := x` still emits the same IR and constructs no member
at all. Reads reach the marker through two `ExactOp2Aux` givens (one per operand
position) and one `DFVal.TC` given, so no per-operator or per-DFType plumbing is
needed. A named `.din` is rejected, since such a binding reads as a snapshot but
behaves as a live view.

IR: `DFVal.Alias.RegDIN` is an `Alias.Consumer`, so it can never reach an
assignment LHS and every generic alias walker treats it as a plain read of the
register on its way to `ToED`.

Lowering: `ToED` already built the `<reg>_din` shadow, its default and the
register update, so reads add four hooks rather than a new stage. Two
corrections were needed for read-modify-write:
- a statement containing a DIN read is no longer promoted to a concurrent
  connection, which had moved the read out of the process and could close a
  combinational loop;
- under VHDL the shadow of a DIN-read register is now a process variable
  published to the design-level signal, since signal assignment evaluates every
  right-hand side against the pre-process value and would increment only once.

Docs: the user guide gains a register model section and a `.din` read section
with the generated VHDL and Verilog, whose designs are pinned by the printer
specs. Implementation notes in devdocs/reg-din.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A `fallThrough` condition is decided on the transition *into* its step, in the
very cycle in which entering that step already assigns registers: an entered
step runs its `onEntry`, a `FALL_THROUGH` for-loop resets the iterator the
`for`-to-`while` rewrite gave it, and a forever-process wrap-around re-runs the
prologue. Reading the registered values there decided the skip on the values the
entering state was about to replace, a cycle behind what the condition names. A
`FALL_THROUGH` for-loop therefore skipped itself on every other pass.

The new `ExplicitFallThroughDIN` stage rewrites every register read in a
`fallThrough` block into a `.din` read. It sits between `DropRTWaits` and
`FlattenStepBlocks`, the only window where a user-written condition and a
loop-generated one exist in one shape: before it, `DropRTWaits` has not yet
synthesized the loop's negated guard; after it, `FirstStepFusion` has already
consumed the condition. Keeping it out of `DropRTWaits` also keeps that stage's
printout honest, the same reasoning that moved the process bootstrap decision
into `FlattenStepBlocks`.

The block is rebuilt member by member rather than edited in place. A register
read shared by two `fallThrough` blocks needs a distinct `.din` read per block,
which a reference redirect keyed on the register cannot express, and redirecting
an existing member's references from inside a `MetaDesign` corrupts the
reference table. Rebuilding also reaches a named intermediate inside the block,
so the whole condition reads one way. Two invariants it respects: the `Ident`
marker stays last, since `DropRTProcess` and `FirstStepFusion` both read the
condition positionally; and each `.din` read is emitted once per reading
reference, since an anonymous value may only be read once.

`FirstStepFusion` gains `substDin`, which resolves a `.din` read to the pending
value within the expansion (one step ahead of `substDcl`, which crosses only the
boundary), and `sameAtFusedEntry`, which lets the subsumption check see past the
`.din` reads: fusion inlines the dispatch into the hook's own cycle with nothing
in between, so both name one value. Without the latter a register-guarded loop
would lose the prologue clone on its guard-false path.

`DFacsimile` gains `.din` read support (`buildRegDIN`): the alias chain resolves
to its declaration with the same `assignTarget` walk a partial write uses, and
the slice comes off `env.getOrElse(dcl, readWV(dcl))`, which is the pending value
the design already closes with. Position sensitivity is free, since `env` is
walked in body order and re-seeded per site program. Because the simulator reads
the elaborated IR, where the hook is still a plain read, it mirrors the stage's
rewrite with `dinGuardMode` rather than seeing it. That retires the
`FALL_THROUGH` for-loop `unsupported` guard, whose only reason was the behavior
this removes: the simulator had been right all along.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…range

The `FALL_THROUGH:` block wrapper tagged every loop elaborated under it,
recursively, with no way to shake the tag off once inside. A nested loop
inherited a property that means nothing for it, and the printer's
consolidation hid the second tag, so a multi-iterator comprehension marked
two loops while printing one.

The recursion is right for `COMB_LOOP` and wrong for `FALL_THROUGH`, and the
difference is what the two annotations are. `COMB_LOOP` marks a REGION: a loop
nested in a combinational loop cannot consume cycles either, and dropping the
tag there would let `SimplifyRTOps` rewrite the inner `for` into a step block
inside a combinational loop. `FALL_THROUGH` marks ONE LOOP: skip me for free if
my guard is false on entry. Nothing about it is regional.

So it moves out of the block form onto the loop it marks:

    while (FALL_THROUGH(go))
    for (i <- FALL_THROUGH(0 until n))

which removes the scoping question rather than answering it: the marker binds
to exactly one loop syntactically, and a nested loop opts in again by saying so.
Per-generator marking in a comprehension becomes expressible for the first time
(`for (i <- 0 until rows; j <- FALL_THROUGH(0 until cols))`); previously it was
unreachable at any cost, since even splitting the comprehension left the wrapper
tagging everything below it.

`LoopFSMPhase` CONSUMES the marker at the two legal positions, unwrapping it and
passing a flag to the loop under construction, so no value or range is ever
tagged and `FALL_THROUGH` is a plugin-replaced stub in the style of
`DFRange.foreach`. Enforcement falls out of the same code path: a legal marker
has been unwrapped by the time the unit is transformed, so whatever still stands
is one the user wrote where no loop can bind it, and `transformUnit` reports it.
That rejects a comprehension guard (a body predicate, whose free skipping would
need an unbounded number of iterator increments in one cycle), part of a
compound condition, and a marker reaching its loop through a `val`.

The IR contract is unchanged: `FallThroughTag` still sits on the loop block, so
`SimplifyRTOps`, `DropRTWaits`, `FlattenStepBlocks`, `FirstStepFusion`,
`DropRTProcess`, `ExplicitFallThroughDIN` and `DFacsimile` are untouched. The
printer moves the marker into the loop header and keeps the enclosing-loop
suppression for `COMB_LOOP` only, where it is still correct; for fall-through it
would now hide a nested opt-in.

Two constraints found while implementing:

  - The condition overload must carry a precise DFHDL type. With a generic `T`,
    the `while`'s expected `Boolean` propagates into the argument and
    `BooleanHack` fires inside the marker, which the plugin rejects.
  - The marker is matched by symbol NAME. `dfhdl.hdl` re-exports `LoopOps`, so
    call sites resolve to an export forwarder and an owner check never matches.
    `HackedGuard` already handles `BooleanHack` this way.

Tests: four position tests via `assertPluginError`; three `SimplifyRTOpsSpec`
goldens for outer-only, inner-only and guard-keeping marking; and
`PrintCodeStringSpec`'s round trip rewritten as a three-deep comprehension with
the outer and innermost marked, pinning that a nested opt-in prints its own
marker. Full suite green (core 173, compiler_stages 830, lib 168, ips 14),
including the 126 DFacsimile lockstep tests over the migrated DUTs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`waitUntil`/`waitWhile` cost their one-cycle minimum even when their
condition already holds on entry, and there was no way to ask for the
zero-cycle skip that `FALL_THROUGH` gives a loop. The mark now applies to
them, written on the wait's own condition, the same way it is written on a
loop's condition or range:

    waitUntil(FALL_THROUGH(ready))
    waitWhile(FALL_THROUGH(busy))

This is the same property, not a new one. `SimplifyRTOps` already lowers a
conditional wait to a loop on the negated condition, so the wait's tags now
carry over to that loop and `waitUntil(FALL_THROUGH(i))` becomes
`while (FALL_THROUGH(!i))`. Everything downstream is untouched: the
`def fallThrough` sub-step `DropRTWaits` synthesizes, `FlattenStepBlocks`,
and `DropRTProcess` all see the loop shape they already handled.

The plugin rewrites `waitUntil(FALL_THROUGH(c))(using dfc, waitScope)` into
`Wait.plugin(c, isUntil, fallThrough)(using dfc)`, so the tag lands on the
`ir.Wait` at construction and the condition value itself is never touched.
The public `waitUntil`/`waitWhile` signatures are unchanged, which keeps the
tag out of reach of hand-written code that could put it on an ED wait, where
it has no printed form. Enforcement falls out of consumption as it does for
the loops: any `FALL_THROUGH` still standing at `transformUnit` is one no
construct could bind, and the error message now names all three legal
positions. An unconditional wait (`1.cy.wait`, `100.ms.wait`) has no
condition that could already hold, and its argument types cannot accept the
mark.

The printer wraps the wait's condition in `FALL_THROUGH(...)`, so the printed
form re-elaborates to the same IR and each wait states for itself whether it
may be skipped.

DFacsimile has to model this or it would silently spend a cycle the hardware
does not. `enterWait` is now the mirror of `enterLoop`'s fall-through path:
same `crossBoundary` forwarding, branches swapped, since here the trigger
being *true* on entry is the skip. Its exit continuation comes from a new
`waitExitConts` map, because the entry edge sits outside the wait's own park
program, and the `walkSeq` fusion-fallback walk lets a marked wait continue
past.

Tests: three plugin-position tests (both legal wait forms, the mark on part
of a compound condition, the mark reaching its wait through a `val`);
goldens through `simplifyRTOps`, the code-string printer, and `dropRTProcess`
(the full FSM, showing the entry into the wait's state jumping straight on
when the condition already holds); and two lockstep DUTs against the real
FSM lowering on both simulator tiers, one of which pins that the skip
decision reads the register value the same cycle just wrote.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Referencing a DFHDL value before its declaration in a design body is a
Scala forward reference: legal in a class body, silently yielding `null`.
The `null` then reached `asIR` and surfaced as a raw NullPointerException
with no position, hierarchy, or hint.

`asIR` is the single front-end to IR crossing, so both the DFMember and
the DFType overloads now detect the `null` there and raise a regular
elaboration error, which carries the operation's position and hierarchy
and lets elaboration continue so all forward references surface at once.

Rendering such an error inside a method body crashed in turn: `getName`
read the design block's instance cache unconditionally, and a block whose
elaboration aborted never got one. It now falls back to the declared meta
name via the existing safe probe.

Documents the rule under Type System, including the `def` exemption that
lets methods and process steps be forward referenced, and cross-references
it from the connectivity statement-order note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The compilation log level was reachable only from source, as a
`given options.CompilerOptions.LogLevel`, so raising it to watch the stages
run meant editing the design and recompiling it. It is now a command-line
option alongside `--cache`, taking any of the seven level names and
defaulting to whatever the givens resolved to:

    <your program> --log info commit

The parsed level feeds both `ElaborationOptions.logLevel` and
`CompilerOptions.logLevel`, the latter being what `StageRunner` reads for its
per-stage messages, its post-stage sanity check at `debug`, and its
code-string dumps at `trace`. Scallop's `choice` validates the name against
`LogLevel.values`, so the level always resolves.

Two supporting changes:

The app's own progress logger follows `--log` only when a level was actually
supplied, since it is deliberately louder than the compiler's `warn` default
and a run without the option must keep reporting its progress. For that to be
observable, the welcome banner and the "no command-line given" notice move
into the successful-parse branch, so `--log off` silences the run from its
first line; a CLI parse error now prints the scallop error alone instead of
the banner first.

`Logger` is a process-global singleton keyed by name, so a level set by one
run leaked into the next in the same JVM (reproduced under sbt's unforked
`runMain`, where `--log off` silenced every later run). `DFApp` now pins its
logger to `INFO` at construction, which is what the wvlet default gave it
before.

Tests assert against `ParsedCommandLine` directly rather than captured
output: the levels only ever surface through the wvlet logger, and that
writes to the `System.err` captured when wvlet first initialized, not
necessarily the stream the spec's harness installed.

Documents the whole command line, not just this option, in a new
"Command-Line Interface" user-guide page: the argument ordering rule, the
mode pipeline and how the default mode is picked, the design arguments and
the literal each type takes, the app and per-mode options, how the command
line relates to the `given options.*` defaults, and the `Werror` exit codes.
Linked from Hello Hardware World, which now shows `help` and a few one-line
overrides.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ches

A named value declared inside a branch of a conditional *expression* has to be
driven somehow, and `ExplicitNamedVars` picks that drive with
`isInEDDomain && !isInProcess`: a connection in an ED domain body, an assignment
everywhere else. Only the connection has nowhere to live. `ExplicitCondExprAssign`
later wraps the whole expression into a `process(all)`, carrying the connection in
with it, and the backend faithfully prints it as an `assign` inside an
`always_comb`, which is not valid Verilog (issue #426):

    always_comb
    begin
      if (sign1) data_out1 = neg1[DATA_WIDTH - 1:0];
      else begin
        assign anon_0   = {data_in1};       // <-- rejected by yosys
        data_out1 = anon_0[DATA_WIDTH - 1:0];
      end
    end

`DB.condExprNamedValCheck` rejects that shape at elaboration. It belongs on the
elaboration path rather than in `SanityCheck` because a user can write it by hand,
which is the deciding question for where a rule goes; the fact that the reported
issue reaches it through a compiler stage does not change that.

The check is deliberately scoped to the concurrent case only. Compiling the same
shape in every scope shows four of the five lower correctly: an ED `process`, an RT
domain body and a DF domain body all emit a plain blocking assignment, and a
conditional *statement* branch is a scope in its own right regardless. Only the ED
domain body miscompiles. So the check reuses `ExplicitNamedVars`' own predicate,
and the comment on both says they must stay in agreement.

Covers the rejected shape plus three accepted ones (ED process, RT body,
conditional statement) in `ElaborationChecksSpec`, and records in the `new-stage`
skill where a rule belongs (elaboration vs sanity), how to localize illegal
generated HDL when nothing throws, and the current `--log trace` command line.

This is the check only; the stages that produce the shape are not fixed yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…not live in

`NamedAliases` names an anonymous value wherever it happened to be built. When
Verilog cannot part-select an arbitrary expression, `NamedVerilogSelection`
names the operand at its point of use, and that point can be a conditional
expression branch directly inside an ED domain body. There the branch is a
concurrent position and not a block, so `ExplicitNamedVars` drove the name by
connection, `ExplicitCondExprAssign` then wrapped the whole expression into a
`process(all)`, and the connection travelled inside it. Verilog printed an
`assign` within an `always_comb`, which yosys rejects (issue #426), and VHDL
printed a signal assignment whose value the next statement read a delta cycle
too early, which nothing rejects at all.

The naming and the placement now happen in one patch. `ChangeRefAndRemove` is
what makes that possible: it drops the original from its illegal position and
redirects its readers, while the move inserts the *named* instance before the
conditional. `FullReplacement` cannot be used, because `Patch.Move` emits a
`Remove` per moved member and `Replace` + `Remove` on one member is not a
mergeable combination. Nor could that combination be added: `patchMembers`
inserts a move's member instances verbatim, so merging the two would relocate
the unnamed instance and drop the renamed one.

Relocating is total rather than best-effort. A conditional expression reached
through the cone travels as its header, its chain blocks and their contents,
since a header cannot leave the branch without the blocks it owns. Two naming
groups can then want to move the same sub-tree (`(if (d) p else q) + 1` names
both the header and the addition), which would duplicate it in the member list,
so each pass acts only on the innermost group and leaves the outer one
anonymous for the next pass to pick up. `transformSubDB` drives that to a fix
point; it terminates because a named value no longer meets any criteria.

`SanityCheck` now runs `condExprNamedValCheck` too. The rule was reachable only
from `DB.check`, which `Design.onCreateEnd` invokes once on the elaborated user
design and nothing invokes again, so it bound the user and left every stage free
to produce the shape silently. Wiring it into the pipeline is what showed that a
follow-up cleanup stage could never be the fix, since the DB in between would
already be invalid.

Also exempts a named conditional header from the check. `ExplicitNamedVars`
drives one through `patchChains`, an assignment per branch and never a
connection, so it nests correctly; rejecting it turned working designs into
elaboration errors.

Adds a `bugfix` skill for the diagnosis methodology this needed: localizing an
illegal shape when nothing throws, deciding which check path a rule belongs to
(and that both may apply), scoping an invariant by compiling the shape in every
scope, and testing without copying an unlicensed reporter's design. Moves the
overlapping material out of `new-stage`, which keeps the stage-authoring parts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`VHDLPrinter.csAssignment` hard-coded `:=`, so every `DFNet.Op.Assignment`
printed as a variable assignment. VHDL picks the operator from the target's
object class, not from the blocking/non-blocking distinction DFHDL picks it
from, so a blocking write to a signal (a design-level `VAR`, or an output
port assigned inside a `process`) emitted illegal VHDL that GHDL rejects.

The classification lives in `DFVal.Dcl.isHDLVariable`, keyed on where the
declaration sits and what modifier it carries:

  process-local, `VAR.SHARED`, HDL-method local/`<> OUT` formal  ->  `:=`
  design-level `VAR`, any port, `<> OUT.NB` formal               ->  `<=`

`csDFValDclWithoutInit` was already answering this exact question to pick
the declaration keyword, so both sites now derive from the one predicate
instead of deciding it independently and drifting into declaring a `signal`
that is then written with `:=`. Its owner match is rewritten to consult
`isHDLVariable` while keeping every branch, including the unreachable ones
(a `VAR.SHARED` inside a process or an HDL method), byte-identical.

To get the target to the backend, `csAssignment` now takes the LHS
declaration instead of a `shared: Boolean`. That bit existed only for
Verilog's `BLKSEQ` lint pragma and left VHDL no way to ask its own question;
passing the declaration lets each backend derive what it needs, and the next
such distinction costs no signature change. The declaration is the one the
printer already dealiased one line down for the `.din` suffix, so this adds
no traversal. Verilog and the DFHDL printer are behavior-identical.

Nothing under `lib/src/test/resources/ref/` changed, and that is the point:
the only `:=` in an architecture anywhere in the reference outputs was
TrueDPR's shared variable, the single form that was already right. The rest
of the branch had no coverage, which is how this survived. The new
`PrintVHDLCodeSpec` test pins all four object classes in one design.

Residual gap, unchanged in kind but now visible: a blocking write to a
design-level signal followed by a read of it in the same process yields the
pre-write value. In a `process(all)` the signal is in the sensitivity list,
so the process re-triggers and settles; with a narrower sensitivity list it
does not. That shape previously emitted illegal VHDL, so this is not a
regression, but legal output is not yet faithful output there. Making it
faithful means promoting such declarations to shared variables under VHDL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An ED method returning a conditional expression crashed the backend with
`Unsupported member for this VerilogPrinter` (and its VHDL equivalent),
because the conditional was never lowered into a statement.

`NamedAnonCondExpr` is the gate that decides which lowering path an
anonymous conditional takes. It exempts one that is "referenced by an
ident", meaning it is the result of an enclosing conditional expression,
which lowers it along with itself. But anonymous idents come from three
unrelated places, not one: a conditional branch result, a fall-through
step block, and a method's return wiring. The method return hit the
exemption meant for the first, so the conditional was never named,
`ExplicitNamedVars` never turned it into a variable, and the backend was
handed an expression it has no form for.

The exemption now applies only to an ident that trails a scope, not one
that trails a method body. It is narrowed in the direction that leaves
every ident kind not enumerated here on its current behaviour, rather
than an allow-list of owners that would also change cases not yet
thought of.

Two consequences of naming it:

`ExplicitCondExprAssign` rule 2 wraps a conditional statement in a
`process(all)` because an ED design body is concurrent and holds no
statements. An HDL method body is procedural, so a statement needs no
wrapping there, and a process inside a method is illegal (SanityCheck
rejects it). Rule 2 now skips an HDL method body.

`ExplicitNamedVars.patchChains` had drifted from its twin in
`ExplicitCondExprAssign`: recursing into a branch whose value is itself
a conditional expression left the ident placeholder behind and never
retyped that nested header to `DFUnit`. This is a pre-existing bug of
its own, and not method-specific: any named value with a nested
conditional in a branch was broken, crashing the Verilog printer in an
ED design and failing an ownership check in a DF design. Verified
against all three stage files reverted.

Verified across ED functions, ED procedures with `<> OUT`, match
expressions, nested conditionals and static `CONSTRET` functions, in
both backends, all passing ghdl/verilator lint. No reference output
changed.

Each stage gets a test in its own spec, and each was verified to fail
with its own fix reverted:

  NamedAnonCondExpr        NamedAnonCondExprSpec (new file, plus the
                           `namedAnonCondExpr` entry point it needed)
  ExplicitCondExprAssign   "ED method body takes a conditional statement
                           without a process wrap"
  ExplicitNamedVars        only visible at the backend: two different
                           IRs print identically in DFHDL, so the nested
                           lowering is pinned in PrintVerilog/PrintVHDL

That last point is why an existing `ExplicitNamedVarsSpec` test had been
covering the nested shape for as long as the bug existed, passing the
whole time. Both skills gained the rule: every stage a change touches
gets a test in its own spec, and every new test is verified to fail with
its fix reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…der check

`SanityCheck.transformSubDB` ran one hand-picked check out of `DB.subDBCheck`
(`condExprNamedValCheck`) and had `orderCheck()` commented out. It now runs the
whole per-design set that elaboration runs — `nameCheck`, the connectivity
checks behind `connectionTable`, `directRefCheck`, `initialCheck`, and
`condExprNamedValCheck` — so a stage's output has to satisfy every rule a user
design satisfies, not just the structural ones, and a check added to
`subDBCheck` is picked up on both paths for free. `rootDBCheck` stays
elaboration-only: those cross-design checks assume a shape the pipeline
deliberately rewrites.

Widening `subDBCheck` cost nothing. `orderCheck` failed 7 tests, all one root
cause.

`FirstStepFusion.substValue` cloned an expression tree and THEN walked the
clone rewiring declaration reads to their forwarded values. But emitting a
forward creates members, so the replacement was appended below the value that
reads it: the reset-site fold of `y.din := i` came out as `AsIs(SInt16)`,
`Const(0)`, `net`, with the alias pointing forward at a constant defined under
it.

`cloneAnonValueAndDepsHere` builds each dependency before the value that reads
it, which is the only order the flat member list accepts, so the substitution
belongs inside the clone rather than after it. It takes a `substDep` hook
applied to every dependency as the clone is built, and `substValue` loses its
`rewire` walk. Note the no-arg overload's default has to be the recursive
clone, not `identity` — `identity` silently stops cloning the cone, which
fails a different 7 tests in the `ApplyRange` / parameterized-selection paths.

This bug is invisible in every printout: an anonymous value prints inline at
its use, so the generated HDL was already correct and no code-string test could
have caught it. That is exactly what the order check is for.

`SanityCheck` had no spec. The new one elaborates a valid design and then
breaks its sub-DB the way a faulty stage would — moving an assignment above
everything it reads, and anonymizing a declaration — since its inputs are DBs
no design can express. Both tests were verified to pass with the checks
reverted and fail with them in place; the fusion fix is pinned by the 7
existing tests.

Two skill statements this falsifies are corrected: /bugfix said `SanityCheck`
"never calls `DB.check`" and that a forward reference is silent because
`orderCheck()` is commented out. /new-stage gains mistake 27 (substituting
into a cloned tree after cloning it inverts the member order) and a section
listing what `SanityCheck` enforces after a stage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`getConstData` memoized one answer per member while taking a
`ConstData.CachePolicy` that decides whether a design parameter resolves to its
applied data or stays an opaque `UnknownConst`. Two policies, two different
answers, one cache slot: whichever consumption came first poisoned the slot for
the other, so the same expression folded or stayed parametric depending only on
what had already read it. The existing half-measure (invalidate when
`this.isDesignParam`) caught the collision on a parameter itself and missed that
it propagates to every expression built over it.

The cache now holds the `Always` answer only. A design parameter is the sole
member whose answer depends on the policy, and it diverges in one direction:
`Always` leaves it `UnknownConst` exactly where the resolving policies fold. So
a non-`UnknownConst` `Always` answer reached no parameter and is provably
policy-independent, while a resolved answer may have come through one and must
never reach the shared slot.

That asymmetry is what keeps the cache instead of disabling it for every
non-default policy: the resolving path consults the shared cache first and
re-walks only on `UnknownConst`, so recomputation is confined to the
parameter-dependent spine while every parameter-free subtree still answers from
cache. `.toScalaInt` and `getConstDataOrDefault` are on paths user code hits
constantly.

The regression pins both consumption orders in one design, since either alone
passes on the broken cache.
The `toScala*` family was mentioned exactly once across the whole site, in
passing, in the `generate for` admonition of the Verilog transition guide.
Issue #430 was filed against that gap: nothing said which accessor belongs to
which type, that a value derived from constants reads just like the constants
it came from, or what reading one costs.

Add a "Reading a Constant into Scala" section to the type system guide with
the accessor table, the constant requirement, the compile-time rejection
message, and a warning on the design specialization that reading a parameter
implies, alongside the plain Scala parameter alternative. Link it from the two
pages a user chasing this lands on first.

The example deliberately keeps `.toScalaInt` out of the range bound and marks
that line as the implicit conversion, so it does not teach the redundant call
it is documenting the alternative to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An elaboration option changed on the command line never reached the
elaboration. A design's context is built by `MetaContextPlacerPhase` from the
options given where the design is DECLARED, which is compiled code and cannot
know about an argv, while `DFApp` applied the command line to its own copy.
Nothing joined the two, so the two options the elaboration itself reads back
were quietly ignored: `--nocache` gated the app's step cache while the
sub-design cache kept serving cached bodies, and `--Werror` was inert
altogether, parsing and appearing in `help` while no one ever read the field
it set.

The plugin already has the rule that fixes this, and it is the ordinary one:
a design takes the DFHDL context in scope where it is instantiated, and only
falls back to the declaration-site options when there is none. The generated
`main` now puts a context in scope at that instantiation site by hosting it
in a local method, and the app supplies it:

    def mkDsn(dfc: DFC): Foo = new Foo(app.getDsnArg("width"))
    app.setDsn(mkDsn(app.getDsnDFC))

`setDsn` still takes its argument by name, so `getDsnDFC` runs at elaboration
time, once `run` has had the command line. Its signature is unchanged, so the
manual path (`dsn.simulate` and friends, through `ManualDFApp`) keeps building
its design in the user's own scope, untouched.

Because `setInitials` seeds the app's elaboration options from the same `@top`
annotation the fallback would have read, this is a no-op on a run with no
command line, and carries every override on a run with one. It also covers the
whole option set rather than the two fields that happen to be reachable today.

`DFApp`'s design-argument context moves onto the same footing. It was
`DFCG()`, whose options fall back to the library defaults resolved at
`DFC.scala`'s own compile site, so a user's `OnError` was ignored when a
command-line argument failed to parse (`exitWithError` reads it, and an
ownerless context reaches that directly from `trydf`). Both contexts now come
from one factory: same options, deliberately separate instances, since a
context owns a `MutableDB` and rebuilding an argument's constant must not
allocate members into the design's own DB.

`getElaborationOptions` is dropped. Its comment claimed the plugin used it for
exactly this, and nothing referenced it; `getDsnDFC` now does the job.

Tests: a fixture design that reports the `cacheEnable` and `Werror` its own
context saw, driven through the generated entry point, since the values are
invisible from outside. Its `tag` argument defeats the app's elaborate-step
cache, which would otherwise skip the body and record nothing.

Documents `DFApp` itself in a new devdoc: what the plugin generates around it,
the five members it resolves by name, where a run's options come from and
which of the two readers each one has, the two contexts, the step pipeline and
its cache keys, and the gotchas. The user-facing `--cache` section now says
that the flag covers the sub-design cache too.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two lessons from issue #430.

The diagnosis genus: when the same code works or fails depending on what ran
before it, minimize by deleting the EARLIER statements, not the failing one. A
failure that disappears when an unrelated line above it goes away is not in the
failing operation at all — something is memoized under a key that does not
capture everything the answer depends on. #430 read like "`.toScalaInt` cannot
fold parameter arithmetic" but only failed once the same expression had already
been consumed as a parametric width. Includes why the fix keeps the cache
rather than disabling it off-policy, and that the asymmetry it exploits runs in
one direction only (the mirrored rule is unsound).

The verification trap: a stashed `compiler_ir` / `core` file can leave zinc
serving the fix you just removed, so the test passes and the honest reading
sends you looking for a different reproducer. The tell is a `MatchError` out of
`compileIncremental` on an unrelated subproject; `clean` before trusting a
stashed run.

The second one amends the "prove the test fails without the fix" rule added
earlier, which assumed stashing is enough.
The sub-design elaboration cache lives BESIDE each project's build output
(`SubDesignDiskCache` resolves `dfhdl-cache` as a sibling of the class
directory), so nothing removed it short of an sbt `clean` of that project.
`clearSandbox` never reached it, which made "clear the sandbox and re-run" a
weaker reset than it reads as.

`clearElabCache` clears every one of those directories, and `clearDFHDL`
clears those plus the sandbox. `clearSandbox` keeps its exact behavior: it is
the one used routinely (FullCompileSpec leaves stale output that misleads),
while the elaboration cache is content-keyed on the code digest, so a stale
entry can never be served and dropping it on every sandbox clear would only
cost elaboration time on the next run.

The cache directories are derived from each project's `classDirectory` rather
than globbed, so they follow the Scala version and any project layout, which
is exactly how the runtime resolves them.

The recursive delete becomes a shared helper, so `clearSandbox` picks up the
two changes that came with the extraction: failures go to the sbt logger
rather than `System.err`, and each removed directory logs one line. Without
that, `clearElabCache` would be entirely silent about what it did.

Documents the commands and the cache directory in CLAUDE.md, records in
elaboration-caching.md that `clearSandbox` does not reach this tier, and
updates the three skills where this changes what the reader does: the trace
caveats in new-stage and bugfix (`--nocache` covers BOTH caches, so
`clearSandbox` was never the equivalent alternative they offered), and a new
section in compile-perf on these caches skewing the runtime half of a
measurement.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The plugin installs a printer so the compiler's own diagnostics name DFHDL
types the way a DFHDL user writes them. It covered a handful of shapes and
placed everything inline in PreTyperPhase; it now lives in its own file and
reaches parity with `ShowType`, which does the same job for the messages DFHDL
itself authors.

Coverage now spans every named dataflow type (bit/boolean, bit vectors, the
integer and fixed-point decimals, host scalars, physical quantities, enums,
opaques, structs and vectors) plus the CONST/VAR/VAL modifiers. The frontend
type is decomposed ONCE, and the IR type it wraps then selects the rendering,
rather than every per-type extractor re-matching the same shape. Width
parameters get their own extractor: a literal prints as its number, a reference
to a width parameter prints that parameter's name, and anything else prints as
an unbounded `Int` (an `IntP.Sig` arithmetic signature could later render as
`WIDTH + 1`). Symbol lookups are cached per run, on the phase instance rather
than a global, since sbt compiles subprojects concurrently in one JVM.

Text is now built the way the compiler builds its own: `controlled` for the
recursion and size limiter, `argsText` for bracketed lists, and real
precedence handling mirroring `RefinedPrinter.toTextInfixType`, so a vector
under `<>` is parenthesized (`(Bit X 4) <> VAR`) while a left-associative `X`
chain is not. A type the printer cannot name is left to the standard printer,
which keeps DFHDL's own generic signatures readable instead of collapsing them
into ShowType's catch-all `DFType`.

Testing this needs `assertPluginError`, not `assertCompileError`:
`typeCheckErrors` packs its diagnostics through `Message.message`, which renders
under a context whose printer the compiler pins to its own `Message.Printer`, so
an installed printer is never consulted. The real run reaches the printer only
because `CustomReporter` re-renders through `Message.toString`, so the snippet
harness now collects the same way, which is both more faithful and the only way
to assert on printed types.

One expected-message update follows from the wider coverage: a match-pattern
error now reads `variable type Bit` where it read `dfhdl.core.DFBit`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@soronpo
soronpo merged commit f022831 into main Aug 1, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant