vj_sim/determinism/kind_guard.rs
1//! DETERMINISM::KIND_GUARD (Task 23, ledger DT-048) — the CI kind-count guard
2//! for the `sv-lang`/`sv-lang-kinds` variant enums that `vj-codegen`'s
3//! already-shipped exhaustive matches depend on staying in sync with.
4//!
5//! `#[non_exhaustive]` on `sv_lang::kinds::ExpressionKind`/`StatementKind`
6//! (`sv-lang-kinds` crate) means a version bump that adds a new variant
7//! compiles cleanly against any `match` with a wildcard fallthrough arm —
8//! silently routing the new variant through whatever the `other => ..` arm
9//! does, with zero compiler signal. Two already-merged, already-shipped
10//! exhaustive matches in the `codegen` track depend on a human re-reviewing
11//! them on every `sv-lang-kinds` version bump:
12//! - `crates/vj-codegen/src/expr.rs::lower_expr`'s `match e.kind()` over
13//! `sv_lang::kinds::ExpressionKind` (shipped commit `9ebd739`).
14//! - `crates/vj-codegen/src/func.rs::lower_stmt`'s `match s.kind()` over
15//! `sv_lang::kinds::StatementKind` (same commit).
16//!
17//! This module does not depend on `vj-codegen` (this crate must not —
18//! `docs/CONTRACTS.md` open question 6) and does not itself touch either
19//! match; it only pins the two kinds' `COUNT` constants to the values those
20//! matches were reviewed against, so a `sv-lang`/`sv-lang-kinds` version bump
21//! that changes either count fails this crate's test suite instead of
22//! silently under-approximating `lower_expr`/`lower_stmt`.
23//!
24//! **`SymbolKind` is guarded as of integration (2026-09-26)**: the netlist
25//! track's elaboration now matches on it (see
26//! `symbol_kind_count_matches_pinned_sv_lang_version` for the sites).
27//! **`TimingControlKind` is guarded as of A2-slice Task S3a**:
28//! `crates/vj-sim/src/process/cfg.rs::CfgBuilder::walk_timed` is its
29//! first dispatch site in this crate (`process::cfg::build_cfg`'s CPS CFG
30//! builder, PR-006/PR-007) -- see
31//! `timing_control_kind_count_matches_pinned_sv_lang_version` below. That
32//! same file's `CfgBuilder::walk_stmt` is also this crate's first
33//! `StatementKind` dispatch site (`vj-codegen`'s `func.rs::lower_stmt`
34//! remains the other), so `statement_kind_count_matches_pinned_sv_lang_
35//! version` below now protects both.
36//!
37//! **Maintenance instruction:** when `sv-lang`/`sv-lang-kinds` is bumped in
38//! the workspace root `Cargo.toml`, re-verify both `lower_expr`/`lower_stmt`
39//! fallthrough arms remain correct for any newly-added variant, then update
40//! this module's two hardcoded `expected` counts below in the same commit as
41//! the version bump.
42
43use std::fmt;
44
45/// A pinned kind-count no longer matches the pinned `sv-lang`/`sv-lang-kinds`
46/// version this module was last reviewed against.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct KindCountDrift {
49 pub kind_name: &'static str,
50 pub expected: u16,
51 pub actual: u16,
52}
53
54impl fmt::Display for KindCountDrift {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 write!(
57 f,
58 "{}::COUNT drifted: expected {} (last reviewed value), found {} -- re-review \
59 every exhaustive match over {} in crates/vj-codegen (see this module's doc \
60 comment for the specific match sites) before updating the expected count",
61 self.kind_name, self.expected, self.actual, self.kind_name
62 )
63 }
64}
65
66impl std::error::Error for KindCountDrift {}
67
68/// The one comparison primitive both the real CI guard tests and the
69/// fixture-bump proof share: `Ok(())` iff `actual == expected`, else
70/// `Err(KindCountDrift { .. })`. Never a panic -- a CI job calling this wants
71/// a typed, reportable failure, not an abort.
72pub fn assert_kind_count(
73 kind_name: &'static str,
74 actual: u16,
75 expected: u16,
76) -> Result<(), KindCountDrift> {
77 if actual == expected {
78 Ok(())
79 } else {
80 Err(KindCountDrift {
81 kind_name,
82 expected,
83 actual,
84 })
85 }
86}
87
88#[cfg(test)]
89mod tests {
90 use super::*;
91
92 /// DT-048: the real, live CI guard. Fails the build the moment a
93 /// `sv-lang`/`sv-lang-kinds` version bump changes `ExpressionKind::COUNT`
94 /// away from the value `crates/vj-codegen/src/expr.rs::lower_expr`'s
95 /// exhaustive match was last reviewed against.
96 #[test]
97 fn expression_kind_count_matches_pinned_sv_lang_version() {
98 assert_eq!(
99 assert_kind_count("ExpressionKind", sv_lang::kinds::ExpressionKind::COUNT, 41,),
100 Ok(()),
101 "sv_lang::kinds::ExpressionKind::COUNT no longer matches this module's pinned \
102 expected value -- re-review crates/vj-codegen/src/expr.rs::lower_expr's \
103 exhaustive match before updating the expected count"
104 );
105 }
106
107 /// DT-048: same shape for `StatementKind`, protecting
108 /// `crates/vj-codegen/src/func.rs::lower_stmt`'s exhaustive match.
109 #[test]
110 fn statement_kind_count_matches_pinned_sv_lang_version() {
111 assert_eq!(
112 assert_kind_count("StatementKind", sv_lang::kinds::StatementKind::COUNT, 32),
113 Ok(()),
114 "sv_lang::kinds::StatementKind::COUNT no longer matches this module's pinned \
115 expected value -- re-review crates/vj-codegen/src/func.rs::lower_stmt's \
116 exhaustive match before updating the expected count"
117 );
118 }
119
120 /// DT-048: `TimingControlKind`, protecting
121 /// `crates/vj-sim/src/process/cfg.rs::CfgBuilder::walk_timed`'s
122 /// `if`/`else-if` dispatch (Task S3a, ledger PR-006/PR-007). Added in the
123 /// same commit as that file's first `TimingControlKind` match site, per
124 /// this module's own maintenance instruction above.
125 #[test]
126 fn timing_control_kind_count_matches_pinned_sv_lang_version() {
127 assert_eq!(
128 assert_kind_count(
129 "TimingControlKind",
130 sv_lang::kinds::TimingControlKind::COUNT,
131 10,
132 ),
133 Ok(()),
134 "sv_lang::kinds::TimingControlKind::COUNT no longer matches this module's pinned \
135 expected value -- re-review crates/vj-sim/src/process/cfg.rs::CfgBuilder::\
136 walk_timed's dispatch before updating the expected count"
137 );
138 }
139
140 /// DT-048: `SymbolKind`, protecting netlist elaboration's whitelist
141 /// matches (`netlist/template.rs::build_members`, `netlist/elaborate.rs`'s
142 /// three member/element/top-symbol matches, `netlist/instance.rs::
143 /// array_path_of`). Each ends in a loud `UnsupportedConstruct` arm, so a new
144 /// variant cannot be silently mishandled, but a bump must still force a
145 /// review of which new kinds those whitelists should accept.
146 #[test]
147 fn symbol_kind_count_matches_pinned_sv_lang_version() {
148 assert_eq!(
149 assert_kind_count("SymbolKind", sv_lang::kinds::SymbolKind::COUNT, 101),
150 Ok(()),
151 "sv_lang::kinds::SymbolKind::COUNT no longer matches this module's pinned \
152 expected value -- re-review the netlist SymbolKind whitelist matches before \
153 updating the expected count"
154 );
155 }
156
157 /// DT-048's own acceptance text: "verified to actually fire on a
158 /// deliberately bumped test fixture, not merely present." The real
159 /// `sv-lang` crate is version-pinned and this track cannot and must not
160 /// edit it to literally bump a kind count, so this test exercises the
161 /// exact comparison function the two tests above call, with a
162 /// deliberately wrong `expected` standing in for "the version was bumped
163 /// and nobody updated this module's constant". This is the test that
164 /// catches a stubbed `assert_kind_count` that always returns `Ok(())`
165 /// regardless of input -- a stub the two tests above alone couldn't rule
166 /// out, since today's real counts happen to equal 41/32.
167 #[test]
168 fn kind_count_guard_fires_when_a_bump_is_simulated() {
169 assert_eq!(
170 assert_kind_count("ExpressionKind", 42, 41),
171 Err(KindCountDrift {
172 kind_name: "ExpressionKind",
173 expected: 41,
174 actual: 42,
175 })
176 );
177 }
178
179 /// The trivial complementary case, so the drift test above isn't the
180 /// only thing pinning `assert_kind_count`'s branch behavior.
181 #[test]
182 fn assert_kind_count_is_ok_when_counts_genuinely_match() {
183 assert_eq!(assert_kind_count("X", 7, 7), Ok(()));
184 }
185}