vj-codegen 0.1.0

LLVM code generator of the vj SystemVerilog simulator.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
//! T5 (`docs/superpowers/plans/2026-10-03-sva.md`): Boolean leaf compilation
//! (16.5/16.6) + 16.6's own prescriptive restrictions on what a leaf may
//! legally contain.
//!
//! ## Compilation
//!
//! A sequence/property Boolean leaf (`a`, `$rose(b)`, `x == y`, 16.6's own
//! "the outcome of the evaluation of an expression is Boolean... interpreted
//! the same way as... the condition of a procedural if statement") is
//! ordinary combinational expression evaluation -- plan §1.2 is explicit
//! that leaves are the one piece of SVA that is *not* automaton-engine
//! machinery, and go through the existing LLVM pipeline unchanged.
//! [`compile_leaf`] is the thin wrapper the plan calls for: it runs
//! [`validate_boolean_leaf`] (static, no codegen) and then hands the
//! expression straight to `process_lower::compile_iff_guard` -- the exact
//! same `extern "C" fn(handle: SimHandle) -> bool` `PredicateFn` compiler
//! `lower_immediate_assertion`'s own `pass` condition and every `wait(expr)`/
//! `iff`-on-edge resume guard already use (`crate::expr::lower_condition` is
//! the one entry point all of these -- including this one -- call through).
//! "Parameterized by *which* `SimHandle`/ring entry the caller will later
//! invoke it against" (plan §1.2/§1.3) needs no extra parameter here: the
//! compiled guard already takes a bare `SimHandle` pointer as its one
//! runtime argument, and nothing about *which* handle (the live arena, or a
//! future Preponed-ring snapshot's handle, §1.3) is baked in at compile
//! time -- the caller picks that by what it passes at the call site, same
//! as `compile_iff_guard`'s two existing callers already do. This is why
//! `leaf_compiles_via_existing_lower_condition_no_new_ir` below can assert
//! zero new codegen: [`compile_leaf`] emits exactly what calling
//! `compile_iff_guard` directly would.
//!
//! ## 16.6 restriction validation
//!
//! [`validate_boolean_leaf`] walks every semantic sub-expression of a leaf
//! (via the same "generic `SemNode` walk" pattern `module.rs::
//! reject_unsupported_in_body` and `sva::op`'s decoders already use) and
//! rejects, with a named [`LoweringError`] (never a panic, never a silent
//! accept), each of 16.6's own enumerated restrictions this task owns:
//!
//! - **Side effects.** 16.6: "evaluation of an expression shall not have
//!   any side effects (e.g., the increment and decrement operators are not
//!   allowed)" -- except "sequence match items with a local variable as the
//!   variable_lvalue [which] may use the C assignment, increment, and
//!   decrement operators" (16.11). That carve-out needs no parameter on
//!   this function: a sequence match item (`(a ##1 b, count++)`) is a
//!   distinct grammar production (`sequence_match_item`, A.2.10) holding its
//!   own statement-shaped assignment/inc-dec, never an operand embedded
//!   inside the *Boolean leaf expression tree* this function walks (T14 owns
//!   validating match items, through its own, separate entry point, once
//!   local-variable formal arguments land). An `ExpressionKind::Assignment`
//!   or a `Preincrement`/`Predecrement`/`Postincrement`/`Postdecrement`
//!   `UnaryOp` found anywhere in a leaf is therefore always illegal here.
//! - **Functions with `output`/`inout`/plain `ref` arguments.** 16.6:
//!   "Functions that appear in expressions shall not contain output, inout,
//!   or ref arguments (const ref is allowed)." `const ref` is `slang`'s
//!   `VariableFlags::CONST` on the `FormalArgument` symbol (confirmed
//!   against the vendored C++: `FormalArgumentSymbol::fromSyntax`,
//!   `source/ast/symbols/VariableSymbols.cpp`, sets exactly that flag when
//!   `header.constKeyword` is present on a `ref` parameter) -- there is no
//!   separate "is this ref const" accessor because const-ref is encoded as
//!   an ordinary variable flag, not a fourth `ArgumentDirection`.
//! - **Non-automatic ("static-lifetime") functions.** 16.6: "Functions shall
//!   be automatic (or preserve no state information) and have no side
//!   effects." A static function whose locals are all `automatic` (formals
//!   are re-initialised by every call) preserves no state and is accepted
//!   (cva6's `countSetBits` check); one with a static-lifetime local
//!   variable is rejected. The "no side effects" half is already covered by the
//!   previous two bullets applied *inside* the callee (every function body
//!   this codebase can elaborate is itself built from expressions/statements
//!   this crate already validates elsewhere); the "shall be automatic" half
//!   is a property of the callee's own declared lifetime
//!   (`Symbol::subroutine_default_lifetime`), checked directly here since no
//!   other task in the plan's coverage table (§2) claims this specific
//!   16.6 bullet.
//! - **`chandle`-typed subexpressions.** 16.6: "Expressions shall not
//!   reference variables of the chandle data type." Checked via
//!   `Expression::expr_type().is_chandle()` at *every* node this function
//!   visits (not just the leaf's own root), so `h == null` is caught at the
//!   `h` sub-node even though the enclosing `==`'s own result type is a
//!   plain bit, not chandle.
//! - **Non-static class properties/methods.** 16.6: "Expressions shall not
//!   reference non-static class properties or methods." Classes are not
//!   yet elaborable through this crate's own pipeline at all (a sibling
//!   track, ARCHITECTURE §10) -- this check exists anyway, exactly per the
//!   plan's own instruction ("n/a until classes land, but check the
//!   construct exists so it fails loudly rather than silently accepting
//!   what it can't yet mean"), verified here against a bare `sv_lang`
//!   `Design` built directly through `Compilation`/`Session` (the frontend
//!   alone elaborates a class member access/method call just fine; it is
//!   only *this codebase's* lowering that has no class support yet).
//!   `slang::ast::ClassPropertySymbol`/`SubroutineSymbol` both carry their
//!   static-vs-instance distinction as plain fields on the *same* symbol
//!   kind (`VariableSymbol::lifetime` for a property,
//!   `MethodFlags::STATIC` for a method) rather than a separate symbol kind
//!   for "static member" -- so the check is `kind == ClassProperty &&
//!   lifetime == Automatic` / `parent.kind == ClassType &&
//!   !flags.contains(STATIC)`, not a `SymbolKind` membership test alone.
//!
//! **Deliberately not checked here** (named, not silently narrowed):
//! 16.6's "expressions in concurrent assertions shall not reference
//! automatic variables" (except inside a procedural concurrent assertion
//! per 16.14.6.1) needs the static-vs-procedural distinction and the
//! "does this term appear in the triggering event expression" analysis
//! T6/T30 build (plan §1.6/§2's own coverage-table row for 16.14.6) -- this
//! module has no access to that context, so it does not attempt a partial,
//! wrong-in-the-procedural-case version of that rule. 16.6's integral-
//! cast-compatibility requirement is already enforced by
//! `crate::expr::lower_condition` itself (`into_packed`'s own error path),
//! so re-checking it here would just be a second, divergent error message
//! for the same rejection. 16.6's dynamic-array/queue/associative-array
//! element-retention guarantee is a *runtime* lifetime property (the
//! collections heap keeping a sampled element alive, §1.8), not a static
//! leaf-expression shape this AST-only walk could ever enforce.
#![allow(dead_code)]

use std::collections::HashMap;

use inkwell::context::Context;
use inkwell::module::Module;
use inkwell::values::FunctionValue;

use sv_lang::kinds::{ExpressionKind, SymbolKind};
use sv_lang::{
    ArgumentDirection, Expression, MethodFlags, SemNode, Symbol, UnaryOp, VariableFlags,
    VariableLifetime,
};
use sv_sim_sim::symid::SymbolId;

use crate::accessor::AccessorFns;
use crate::error::LoweringError;
use crate::process_lower::{SignalSlot, compile_iff_guard, compile_negated_iff_guard};
use crate::value::ValueRepr;

/// Compiles a sequence/property Boolean leaf (16.5/16.6) into a standalone
/// `extern "C" fn(handle: SimHandle) -> bool` guard, after checking every
/// 16.6 restriction this task owns ([`validate_boolean_leaf`]). See the
/// module doc comment for why this adds no codegen beyond calling
/// `process_lower::compile_iff_guard` directly.
#[allow(clippy::too_many_arguments)]
pub fn compile_leaf<'ctx, 'd>(
    ctx: &'ctx Context,
    module: &Module<'ctx>,
    fns: &AccessorFns<'ctx>,
    signals: &HashMap<SymbolId, SignalSlot>,
    scope: &Symbol<'d>,
    name: &str,
    expr: Expression<'d>,
    value_repr: ValueRepr,
) -> Result<FunctionValue<'ctx>, LoweringError> {
    validate_boolean_leaf(expr, scope)?;
    compile_iff_guard(ctx, module, fns, signals, scope, name, expr, value_repr)
}

/// [`compile_leaf`] for a leaf the builder marked `LeafTable::is_negated` (the
/// gap sample of a goto/nonconsecutive repetition, 16.9.2): the guard of the
/// expression `!expr`, which holds only where `expr` is a KNOWN zero. An `x`/`z`
/// `expr` makes it false -- as the hand-written `!b` of the equivalent
/// `!b[*0:$] ##1 b` is, since `!x` is `x` -- which `!compile_leaf(expr)` would
/// not give.
#[allow(clippy::too_many_arguments)]
pub fn compile_negated_leaf<'ctx, 'd>(
    ctx: &'ctx Context,
    module: &Module<'ctx>,
    fns: &AccessorFns<'ctx>,
    signals: &HashMap<SymbolId, SignalSlot>,
    scope: &Symbol<'d>,
    name: &str,
    expr: Expression<'d>,
    value_repr: ValueRepr,
) -> Result<FunctionValue<'ctx>, LoweringError> {
    validate_boolean_leaf(expr, scope)?;
    compile_negated_iff_guard(ctx, module, fns, signals, scope, name, expr, value_repr)
}

/// Whether static-lifetime function `sub` declares a static-lifetime local
/// variable (itself or in a nested block): state it preserves across calls
/// (LRM 16.6). Formals are re-initialised by every call and do not count.
fn has_static_local(sub: &Symbol<'_>) -> bool {
    sub.members().any(|m| {
        (matches!(m.kind(), SymbolKind::Variable)
            && m.variable_lifetime() == VariableLifetime::Static)
            || (matches!(m.kind(), SymbolKind::StatementBlock) && has_static_local(&m))
    })
}

/// 16.6's restrictions on a Boolean leaf expression, checked over `expr`
/// and every one of its sub-expressions. `scope` is only used to name a
/// rejection's location (every other decoder in this crate's `sva` modules
/// takes the same parameter for the same reason).
///
/// A node that casts to neither [`Expression`] nor `AssertionExpr` (e.g. a
/// `Clocking` node's own clock-event `TimingControl`, reachable if `expr`
/// itself turns out to embed one) is correctly skipped, same as
/// [`super::collect_boolean_leaves`]. A node that casts to `AssertionExpr`
/// -- reachable here only via a nested named sequence/property reference's
/// `.body` (an `AssertionInstanceExpression`'s own semantic child, see
/// [`super::assertion_expr_leaf_children`]'s doc comment) -- is NOT silently
/// dropped: its own leaf-bearing children (same helper [`collect_boolean_
/// leaves`](super::collect_boolean_leaves) uses, so a `SequenceWithMatch`'s
/// match items are excluded here too, not just at the top level) are pushed
/// back onto the worklist instead of stopping the walk cold.
pub fn validate_boolean_leaf<'d>(
    expr: Expression<'d>,
    scope: &Symbol<'d>,
) -> Result<(), LoweringError> {
    let mut stack: Vec<SemNode<'d>> = vec![expr.as_sem_node()];
    while let Some(node) = stack.pop() {
        if let Some(e) = node.as_expression() {
            check_one(e, scope)?;
            stack.extend(e.as_sem_node().children());
        } else if let Some(a) = node.as_assertion_expr() {
            stack.extend(super::assertion_expr_leaf_children(a));
        }
    }
    Ok(())
}

/// The single-node half of [`validate_boolean_leaf`]'s walk -- every check
/// that depends only on `expr` itself, never its descendants (the caller
/// pushes `expr`'s children back onto its own worklist).
fn check_one<'d>(expr: Expression<'d>, scope: &Symbol<'d>) -> Result<(), LoweringError> {
    // Side effects: a direct assignment (`=`/`+=`/.../`<=`) or an
    // increment/decrement operator, anywhere in the leaf. 16.11's sequence
    // match items are a different grammar production entirely (module doc
    // comment) and never reach this walk. Deliberately two independent
    // `if`s, not a `match expr.kind() { .. => .., _ => {} }` -- this is a
    // two-kind REJECT-LIST membership test ("is this expression one of the
    // two forbidden shapes; if not, keep walking"), not an exhaustive
    // classification of `ExpressionKind`, the same distinction `module.rs::
    // check_no_class_declarations`'s own doc comment draws against `xtask
    // lint-unimplemented`'s Kind-typed-match heuristic.
    if expr.kind() == ExpressionKind::Assignment {
        return Err(LoweringError::unsupported_at(
            "an assignment inside a concurrent-assertion Boolean leaf (LRM 16.6: \"evaluation of \
             an expression shall not have any side effects\" -- only a sequence match item's own \
             local-variable assignment, 16.11, is exempt, and that is a separate grammar \
             production this leaf-expression walk never reaches)",
            scope,
        ));
    }
    if expr.kind() == ExpressionKind::UnaryOp
        && let Some(op) = expr.unary_op()
        && matches!(
            op,
            UnaryOp::Preincrement
                | UnaryOp::Predecrement
                | UnaryOp::Postincrement
                | UnaryOp::Postdecrement
        )
    {
        return Err(LoweringError::unsupported_at(
            &format!(
                "a {op:?} operator inside a concurrent-assertion Boolean leaf (LRM 16.6: \"the \
                 increment and decrement operators are not allowed\")"
            ),
            scope,
        ));
    }

    // `chandle`: checked on every node's own result type, not just the
    // leaf's root, so `h == null` is caught at `h` itself.
    if let Some(ty) = expr.expr_type()
        && ty.is_chandle()
    {
        return Err(LoweringError::unsupported_at(
            "a chandle-typed subexpression inside a concurrent-assertion Boolean leaf (LRM 16.6: \
             \"Expressions shall not reference variables of the chandle data type\")",
            scope,
        ));
    }

    // Non-static class property access (`obj.prop`).
    if expr.kind() == ExpressionKind::MemberAccess
        && let Some(member) = expr.member_symbol()
        && member.kind() == SymbolKind::ClassProperty
        && member.variable_lifetime() == VariableLifetime::Automatic
    {
        return Err(LoweringError::unsupported_at(
            "a non-static class property reference inside a concurrent-assertion Boolean leaf \
             (LRM 16.6: \"Expressions shall not reference non-static class properties or \
             methods\")",
            scope,
        ));
    }

    // Function calls: non-static class methods, and (regardless of
    // class-ness) output/inout/plain-ref formal arguments or a
    // static-lifetime callee.
    if expr.kind() == ExpressionKind::Call
        && let Some(sub) = expr.call_subroutine()
    {
        if let Some(parent) = sub.parent()
            && parent.kind() == SymbolKind::ClassType
            && !sub.subroutine_flags().contains(MethodFlags::STATIC)
        {
            return Err(LoweringError::unsupported_at(
                "a non-static class method call inside a concurrent-assertion Boolean leaf (LRM \
                 16.6: \"Expressions shall not reference non-static class properties or \
                 methods\")",
                scope,
            ));
        }
        if sub.subroutine_default_lifetime() == Some(VariableLifetime::Static)
            && has_static_local(&sub)
        {
            return Err(LoweringError::unsupported_at(
                &format!(
                    "a call to static-lifetime function `{}` that keeps static local state \
                     inside a concurrent-assertion Boolean leaf (LRM 16.6: \"Functions shall be \
                     automatic (or preserve no state information) and have no side effects\")",
                    sub.name()
                ),
                scope,
            ));
        }
        for arg in sub
            .members()
            .filter(|m| m.kind() == SymbolKind::FormalArgument)
        {
            match arg.formal_argument_direction() {
                ArgumentDirection::Out | ArgumentDirection::InOut => {
                    return Err(LoweringError::unsupported_at(
                        &format!(
                            "a call to `{}`, whose formal argument `{}` is output/inout, inside \
                             a concurrent-assertion Boolean leaf (LRM 16.6: \"Functions that \
                             appear in expressions shall not contain output, inout, or ref \
                             arguments (const ref is allowed)\")",
                            sub.name(),
                            arg.name()
                        ),
                        scope,
                    ));
                }
                ArgumentDirection::Ref if !arg.variable_flags().contains(VariableFlags::CONST) => {
                    return Err(LoweringError::unsupported_at(
                        &format!(
                            "a call to `{}`, whose formal argument `{}` is a plain (non-const) \
                             ref, inside a concurrent-assertion Boolean leaf (LRM 16.6: \"...or \
                             ref arguments (const ref is allowed)\")",
                            sub.name(),
                            arg.name()
                        ),
                        scope,
                    ));
                }
                ArgumentDirection::In | ArgumentDirection::Ref => {}
            }
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use sv_lang::kinds::{AssertionExprKind, SymbolKind as SK};
    use sv_lang::{AnalysisFlags, Compilation, Session};
    use sv_sim_sim::symid::SymbolUid;

    /// Compiles `property_body` as a clocked `assert property`, and hands
    /// the single `Expression` that is the assert's own clocked body's one
    /// semantic child -- an `AssertionInstanceExpression` for every fixture
    /// below (a bare named sequence/property reference) -- straight to `f`,
    /// bypassing `collect_boolean_leaves` entirely. This independently
    /// exercises `validate_boolean_leaf`'s OWN internal walk (the second
    /// half of the review finding: the walk has no case for "an
    /// `Expression` whose own child is an `AssertionExpr`", a gap
    /// `collect_boolean_leaves`'s fix alone would never catch, since that
    /// function's fix means `validate_boolean_leaf` is never again CALLED
    /// with a bare `AssertionInstanceExpression` from production code --
    /// this test proves the walk itself is independently correct, the same
    /// discipline `leaf_rejects_*`'s existing tests already apply by
    /// binding directly rather than going through `module.rs`).
    fn with_named_reference_leaf<R>(
        extra_decls: &str,
        reference: &str,
        f: impl FnOnce(Expression<'_>, Symbol<'_>) -> R,
    ) -> R {
        let src = format!(
            "module m;\n\
             {extra_decls}\n\
             logic clk, a, b;\n\
             int cnt;\n\
             always_ff @(posedge clk) begin\n\
               assert property (@(posedge clk) {reference});\n\
             end\n\
             endmodule\n"
        );
        let session = Session::new();
        let mut comp = Compilation::new(&session).unwrap();
        comp.add_source(&src).unwrap();
        let design = comp.compile().unwrap();
        assert!(
            !design.has_fatal_errors(),
            "fixture failed to compile: {src}"
        );
        let body = design
            .top_instances()
            .next()
            .unwrap()
            .instance_body()
            .unwrap();
        let analysis = design.analyze(AnalysisFlags::NONE, 1).unwrap();
        let proc = analysis.procedures(body).next().unwrap();
        let assertion = analysis.assertions(proc.symbol()).next().unwrap();
        let clocking = assertion.root().as_assertion_expr().unwrap();
        assert_eq!(clocking.kind(), AssertionExprKind::Clocking);
        let reference_expr = clocking.children()[1]
            .as_assertion_expr()
            .unwrap()
            .children()[0]
            .as_expression()
            .unwrap();
        assert_eq!(
            reference_expr.kind(),
            ExpressionKind::AssertionInstance,
            "fixture must bind to a bare named sequence/property reference"
        );
        f(reference_expr, body)
    }

    /// Review-fix regression: calling `validate_boolean_leaf` directly on
    /// an `AssertionInstanceExpression` (a named sequence reference) must
    /// recurse into its `.body` and find the same static-lifetime-function
    /// violation the byte-identical inline leaf is already rejected for --
    /// not silently drop the body and return `Ok`.
    #[test]
    fn validate_boolean_leaf_recurses_through_named_sequence_reference() {
        with_named_reference_leaf(
            "function static int f(); int n; n = n + 1; return n; endfunction\n\
             sequence seq1; f() == 1; endsequence",
            "seq1",
            |leaf, scope| {
                let err = validate_boolean_leaf(leaf, &scope).expect_err(
                    "a static-lifetime function call inside a named sequence's body must still \
                     be rejected when validate_boolean_leaf is called on the reference directly",
                );
                assert!(err.message.contains("static-lifetime"), "error was: {err}");
            },
        );
    }

    /// Review-fix regression (compound case): an inline sequence match item
    /// reached THROUGH a named sequence reference's body (not only at the
    /// top level `collect_boolean_leaves` already covers) must still not be
    /// rejected -- `validate_boolean_leaf`'s own `AssertionExpr` recursion
    /// must apply the same `SequenceWithMatch` match-item exclusion
    /// `assertion_expr_leaf_children` does, not just blindly recurse into
    /// every child once it starts walking assertion-expr nodes.
    #[test]
    fn validate_boolean_leaf_does_not_reject_match_item_reached_through_named_sequence() {
        with_named_reference_leaf(
            "sequence seq2; (a ##1 b, cnt++); endsequence",
            "seq2",
            |leaf, scope| {
                validate_boolean_leaf(leaf, &scope).expect(
                    "an inline sequence match item reached through a named sequence reference \
                     is still LRM-legal (16.11) and must not be rejected",
                );
            },
        );
    }

    /// Review-fix regression (r2), mirroring the `SequenceWithMatch` case
    /// directly above for `first_match` (LRM 16.9.3/A.2.10): a `first_match`
    /// match item reached through a named sequence reference must still not
    /// be rejected -- `validate_boolean_leaf`'s own `AssertionExpr`
    /// recursion must apply the same `FirstMatch` match-item exclusion
    /// `assertion_expr_leaf_children` does, not just the `SequenceWithMatch`
    /// case.
    ///
    /// Deliberately does NOT reuse [`with_named_reference_leaf`] (unlike
    /// the `SequenceWithMatch` test directly above): that helper declares
    /// `extra_decls` (the `sequence` here) textually BEFORE `logic a, b`/
    /// `int cnt`, and that forward reference turns out to make slang bind
    /// `a`, `b` and the `cnt++` match item as children-less nodes at THIS
    /// level (verified live: with `a`/`b`/`cnt` forward-referenced,
    /// `SequenceWithMatch`/`FirstMatch`'s own `children()` call already
    /// returns only the sub-sequence, with the match item entirely absent
    /// -- not filtered by this module's fix, just never present -- so that
    /// sibling test's assertion passes vacuously regardless of whether the
    /// `FirstMatch`/`SequenceWithMatch` exclusion exists at all). Declaring
    /// `a`/`b`/`cnt` BEFORE the `sequence` avoids that forward-reference
    /// artifact and lets the match item actually reach this walk, so this
    /// test genuinely exercises the fix (confirmed: it fails with the
    /// pre-fix `SequenceWithMatch`-only guard, naming the `cnt++`
    /// postincrement as a false 16.6 violation, exactly like the
    /// `module_discovery` and `collect_boolean_leaves`-level regressions).
    #[test]
    fn validate_boolean_leaf_does_not_reject_first_match_match_item_reached_through_named_sequence()
    {
        let src = "module m;\n\
             logic clk, a, b;\n\
             int cnt;\n\
             sequence seq3; first_match(a ##1 b, cnt++); endsequence\n\
             always_ff @(posedge clk) begin\n\
               assert property (@(posedge clk) seq3);\n\
             end\n\
             endmodule\n";
        let session = Session::new();
        let mut comp = Compilation::new(&session).unwrap();
        comp.add_source(src).unwrap();
        let design = comp.compile().unwrap();
        assert!(
            !design.has_fatal_errors(),
            "fixture failed to compile: {src}"
        );
        let body = design
            .top_instances()
            .next()
            .unwrap()
            .instance_body()
            .unwrap();
        let analysis = design.analyze(AnalysisFlags::NONE, 1).unwrap();
        let proc = analysis.procedures(body).next().unwrap();
        let assertion = analysis.assertions(proc.symbol()).next().unwrap();
        let clocking = assertion.root().as_assertion_expr().unwrap();
        let reference_expr = clocking.children()[1]
            .as_assertion_expr()
            .unwrap()
            .children()[0]
            .as_expression()
            .unwrap();
        assert_eq!(
            reference_expr.kind(),
            ExpressionKind::AssertionInstance,
            "fixture must bind to a bare named sequence reference"
        );
        validate_boolean_leaf(reference_expr, &body).expect(
            "a first_match match item reached through a named sequence reference is still \
             LRM-legal (16.9.3/16.11) and must not be rejected",
        );
    }

    /// Compiles `module m; {extra_decls} logic a, b; initial
    /// wait({expr_text}); endmodule`, and hands `wait`'s own condition
    /// `Expression` -- ORDINARILY bound (never inside an `assert
    /// property`) -- plus the containing scope `Symbol` to `f`.
    ///
    /// This deliberately avoids a real assertion context: `validate_
    /// boolean_leaf` is a pure, context-independent AST-shape check, but
    /// slang's OWN binder already enforces several of 16.6's same
    /// restrictions, *specifically gated on `ASTFlags::AssertionExpr`*
    /// (confirmed against the vendored binder: `source/ast/expressions/
    /// CallExpression.cpp`'s `checkOutputArgs`/`SubroutineSymbol::
    /// hasOutputArgs` for the output/inout/non-const-ref case -- using the
    /// identical `direction != In && (direction != Ref || !CONST)` formula
    /// this module's own `check_one` independently re-derived from the LRM
    /// text -- and `source/ast/expressions/MiscExpressions.cpp`'s
    /// `CHandleInAssertion` check). Inside a real `assert property`, a
    /// violating construct is marked `bad()` by slang itself, and a
    /// `bad()` node's generic `visit()` returns before recording any
    /// child (`ASTVisitor::visit`'s own `if (t.bad()) return;` guard) --
    /// so the wrapping `AssertionExpr`'s `children()` comes back EMPTY,
    /// never a reachable leaf `Expression` this module could even pass to
    /// its own validator. Binding the identical expression as an ordinary
    /// `wait(...)` condition instead -- the exact same binding
    /// `compile_iff_guard`'s own existing tests (`tests/iff_guard.rs`)
    /// already use -- sidesteps that native rejection entirely and lets
    /// each test deterministically exercise THIS module's own check.
    fn with_plain_expr<R>(
        extra_decls: &str,
        expr_text: &str,
        f: impl FnOnce(Expression<'_>, Symbol<'_>) -> R,
    ) -> R {
        let src = format!(
            "module m;\n\
             {extra_decls}\n\
             logic a, b;\n\
             initial wait({expr_text});\n\
             endmodule\n"
        );
        let design = crate::frontend::compile_source(&src).unwrap();
        assert!(
            !design.has_fatal_errors(),
            "fixture failed to compile: {src}"
        );
        let body = design
            .top_instances()
            .next()
            .unwrap()
            .instance_body()
            .unwrap();
        let proc_sym = body
            .members()
            .find(|s| s.kind() == SK::ProceduralBlock)
            .unwrap();
        let root = sv_sim_sim::process::cfg::proc_body(&proc_sym).unwrap();
        let expr = root.condition().expect("wait(...) must have a condition");
        f(expr, body)
    }

    #[test]
    fn leaf_compiles_via_existing_lower_condition_no_new_ir() {
        with_plain_expr("", "a == b", |leaf, scope| {
            let a_sym = scope.members().find(|m| m.name() == "a").unwrap();
            let b_sym = scope.members().find(|m| m.name() == "b").unwrap();
            let mut signals = HashMap::new();
            signals.insert(
                a_sym.uid(),
                SignalSlot {
                    slot: 0,
                    width: 1,
                    signed: false,
                    class: crate::process_lower::SignalSlotClass::Inline,
                },
            );
            signals.insert(
                b_sym.uid(),
                SignalSlot {
                    slot: 1,
                    width: 1,
                    signed: false,
                    class: crate::process_lower::SignalSlotClass::Inline,
                },
            );
            assert_eq!(scope.kind(), SK::InstanceBody);

            // Reference module: compile the identical leaf straight through
            // `compile_iff_guard`.
            let ctx_a = Context::create();
            let module_a = ctx_a.create_module("leaf_direct");
            let fns_a = crate::accessor::declare_accessor_fns(&ctx_a, &module_a);
            compile_iff_guard(
                &ctx_a,
                &module_a,
                &fns_a,
                &signals,
                &scope,
                "guard",
                leaf,
                ValueRepr::FourState,
            )
            .unwrap();

            // The leaf under test: compile the same leaf through
            // `compile_leaf` (validation + the same call).
            let ctx_b = Context::create();
            let module_b = ctx_b.create_module("leaf_wrapped");
            let fns_b = crate::accessor::declare_accessor_fns(&ctx_b, &module_b);
            compile_leaf(
                &ctx_b,
                &module_b,
                &fns_b,
                &signals,
                &scope,
                "guard",
                leaf,
                ValueRepr::FourState,
            )
            .unwrap();

            assert_eq!(
                module_a.get_functions().count(),
                module_b.get_functions().count(),
                "compile_leaf must emit exactly the functions compile_iff_guard alone would -- \
                 zero new codegen IR for leaves (plan T5 acceptance)"
            );
        });
    }

    #[test]
    fn leaf_rejects_output_arg_function_call() {
        with_plain_expr(
            "function automatic int f(output int o); o = 1; return o; endfunction\n\
             int unused_out;",
            "f(unused_out) == 1",
            |leaf, scope| {
                let err = validate_boolean_leaf(leaf, &scope)
                    .expect_err("an output-arg function call must be rejected");
                assert!(err.message.contains("output/inout"), "error was: {err}");
            },
        );
    }

    #[test]
    fn leaf_accepts_const_ref_arg_function_call() {
        with_plain_expr(
            "function automatic int f(const ref int r); return r; endfunction\n\
             int x;",
            "f(x) == 1",
            |leaf, scope| {
                validate_boolean_leaf(leaf, &scope)
                    .expect("a const ref argument is explicitly legal per LRM 16.6");
            },
        );
    }

    #[test]
    fn leaf_rejects_plain_ref_arg_function_call() {
        with_plain_expr(
            "function automatic int f(ref int r); return r; endfunction\n\
             int x;",
            "f(x) == 1",
            |leaf, scope| {
                let err = validate_boolean_leaf(leaf, &scope)
                    .expect_err("a plain (non-const) ref argument must be rejected");
                assert!(err.message.contains("non-const) ref"), "error was: {err}");
            },
        );
    }

    #[test]
    fn leaf_rejects_side_effecting_increment_outside_match_item() {
        with_plain_expr("int c;", "(c++ > 0)", |leaf, scope| {
            let err = validate_boolean_leaf(leaf, &scope).expect_err(
                "++ inside a plain Boolean leaf (not a sequence match item) must be rejected",
            );
            assert!(err.message.contains("Postincrement"), "error was: {err}");
        });
    }

    #[test]
    fn leaf_rejects_assignment_inside_leaf() {
        with_plain_expr("int c;", "((c = 1) == 1)", |leaf, scope| {
            let err = validate_boolean_leaf(leaf, &scope)
                .expect_err("a plain assignment inside a Boolean leaf must be rejected");
            assert!(err.message.contains("assignment"), "error was: {err}");
        });
    }

    #[test]
    fn leaf_rejects_chandle_reference() {
        with_plain_expr("chandle h;", "(h != null)", |leaf, scope| {
            let err = validate_boolean_leaf(leaf, &scope)
                .expect_err("a chandle reference must be rejected");
            assert!(err.message.contains("chandle"), "error was: {err}");
        });
    }

    #[test]
    fn leaf_rejects_static_lifetime_function_call() {
        with_plain_expr(
            "function static int f(); int n; n = n + 1; return n; endfunction",
            "f() == 1",
            |leaf, scope| {
                let err = validate_boolean_leaf(leaf, &scope)
                    .expect_err("a static-lifetime function call must be rejected");
                assert!(err.message.contains("static-lifetime"), "error was: {err}");
            },
        );
    }

    #[test]
    fn leaf_accepts_ordinary_automatic_function_call() {
        with_plain_expr(
            "function automatic int f(int x); return x + 1; endfunction",
            "f(a) == 1",
            |leaf, scope| {
                validate_boolean_leaf(leaf, &scope).expect(
                    "an ordinary automatic function call, with only input arguments, is legal",
                );
            },
        );
    }

    #[test]
    fn leaf_rejects_non_static_class_property() {
        with_plain_expr(
            "class C; int x; endclass\n\
             C c;",
            "(c.x == 1)",
            |leaf, scope| {
                let err = validate_boolean_leaf(leaf, &scope)
                    .expect_err("a non-static class property reference must be rejected");
                assert!(
                    err.message.contains("non-static class property"),
                    "error was: {err}"
                );
            },
        );
    }

    #[test]
    fn leaf_accepts_static_class_property() {
        with_plain_expr(
            "class C; static int x; endclass",
            "(C::x == 1)",
            |leaf, scope| {
                validate_boolean_leaf(leaf, &scope)
                    .expect("a static class property reference is legal (not an instance member)");
            },
        );
    }

    /// The two signal samples of a JIT'd guard, reached through `SimHandle`.
    extern "C" fn sample_scalar(h: sv_sim_abi::SimHandle, slot: sv_sim_abi::SlotId) -> u128 {
        // SAFETY: `h.0` is the `[u128; 2]` the test below keeps alive across the
        // call.
        let samples = unsafe { &*(h.0 as *const [u128; 2]) };
        samples[slot.0 as usize]
    }

    /// `(val, unk)` of a 1-bit sample: `0`, `1`, or `x`.
    fn sample(c: char) -> u128 {
        match c {
            '0' => 0,
            '1' => 1,
            'x' => 1 << 64,
            other => panic!("bad sample {other:?}"),
        }
    }

    /// LRM 16.9.2 defines `b[->n]` through `!b[*0:$]`, and `!x` is `x` (11.4.7):
    /// the negated leaf of a goto gap must be FALSE on an `x` operand, exactly as
    /// the guard of the hand-written `!b` is, and unlike the complement of the
    /// plain leaf (which is true there). Run on the JIT'd functions.
    #[test]
    fn negated_leaf_is_false_on_an_x_operand() {
        // (expression, a, b, plain leaf, negated leaf)
        let cases = [
            ("b", '0', '0', 0u8, 1u8),
            ("b", '0', '1', 1, 0),
            ("b", '0', 'x', 0, 0),
            // `a && b` with an unknown `b`: `1 && x` is `x`, `0 && x` is `0`.
            ("a && b", '1', 'x', 0, 0),
            ("a && b", '0', 'x', 0, 1),
            ("a && b", '1', '1', 1, 0),
            ("a && b", '1', '0', 0, 1),
            // `a == b`: an unknown operand makes the comparison `x`.
            ("a == b", '1', 'x', 0, 0),
            ("a == b", '1', '0', 0, 1),
            ("a == b", 'x', 'x', 0, 0),
        ];
        for (text, a, b, plain, negated) in cases {
            with_plain_expr("", text, |leaf, scope| {
                let mut signals = HashMap::new();
                for (i, name) in ["a", "b"].into_iter().enumerate() {
                    let sym = scope.members().find(|m| m.name() == name).unwrap();
                    signals.insert(
                        sym.uid(),
                        SignalSlot {
                            slot: i as u32,
                            width: 1,
                            signed: false,
                            class: crate::process_lower::SignalSlotClass::Inline,
                        },
                    );
                }
                let samples = [sample(a), sample(b)];
                let handle = sv_sim_abi::SimHandle(&samples as *const [u128; 2] as *mut _);
                let run = |negated_leaf: bool| -> u8 {
                    let ctx = Context::create();
                    let module = ctx.create_module("negated_leaf");
                    let fns = crate::accessor::declare_accessor_fns(&ctx, &module);
                    let build = if negated_leaf {
                        compile_negated_leaf
                    } else {
                        compile_leaf
                    };
                    build(
                        &ctx,
                        &module,
                        &fns,
                        &signals,
                        &scope,
                        "guard",
                        leaf,
                        ValueRepr::FourState,
                    )
                    .unwrap();
                    let get = module
                        .get_function(sv_sim_abi::accessor::SV_GET_SCALAR_SYMBOL)
                        .unwrap();
                    let jit = crate::jit::Jit::from_module(module).unwrap();
                    jit.add_global_mapping(get, sample_scalar as *const () as usize);
                    let raw = jit.get_raw("guard").expect("guard must JIT-compile");
                    // SAFETY: the guard's LLVM signature is `i8(ptr)`.
                    let guard: extern "C" fn(sv_sim_abi::SimHandle) -> u8 =
                        unsafe { std::mem::transmute(raw) };
                    guard(handle)
                };
                assert_eq!(run(false), plain, "`{text}` with a={a} b={b}");
                assert_eq!(run(true), negated, "`!({text})` with a={a} b={b}");
            });
        }
    }
}