Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .config/nextest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,12 @@ serial-toolchain-installs = { max-threads = 1 }
# can briefly hold a handle on the file, causing the rename to fail with
# "Access is denied (os error 5)". Retrying with back-off is the standard
# mitigation for this class of transient failure.
filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui)"
#
# The final clause `(binary(ui) & test(=ui))` catches crates whose test
# integration binary is `tests/ui.rs` with a top-level `fn ui()` — the test
# name reported to nextest is plain `ui`, not `ui::ui`, so the substring
# match `test(ui::ui)` does not capture them (e.g. bumpy_road_function).
filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | (binary(ui) & test(=ui))"
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
test-group = "serial-dylint-ui"
retries = { backoff = "exponential", count = 2, delay = "5s" }

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions crates/bumpy_road_function/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ rstest = { workspace = true }
rstest-bdd = { workspace = true }
rstest-bdd-macros = { workspace = true }
dylint_testing = { workspace = true }
toml = { workspace = true }
whitaker = { workspace = true }
whitaker-common = { workspace = true }
camino = { workspace = true }
Expand Down
10 changes: 9 additions & 1 deletion crates/bumpy_road_function/src/analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@
//! APIs allows unit and behavioural testing without compiling the compiler
//! driver.

/// Default smoothed-signal threshold at which a bump is considered active.
///
/// This constant is the single source of truth for the default threshold.
/// [`Settings::default`] references it so the value cannot drift across
/// locations. Configuration files and `.stderr` expectations should be
/// validated against this constant in tests.
pub const DEFAULT_THRESHOLD: f64 = 2.5;

/// Weighting applied to signal segments.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Weights {
Expand Down Expand Up @@ -44,7 +52,7 @@ pub struct Settings {
impl Default for Settings {
fn default() -> Self {
Self {
threshold: 3.0,
threshold: DEFAULT_THRESHOLD,
window: 3,
min_bump_lines: 2,
weights: Weights::default(),
Expand Down
28 changes: 27 additions & 1 deletion crates/bumpy_road_function/tests/analysis_behaviour.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
extern crate rustc_driver;

use bumpy_road_function::analysis::{
Settings, Weights, detect_bumps, normalise_settings, top_two_bumps,
DEFAULT_THRESHOLD, Settings, Weights, detect_bumps, normalise_settings, top_two_bumps,
};
use rstest::fixture;
use rstest::rstest;
Expand Down Expand Up @@ -225,3 +225,29 @@ fn scenario_negative_threshold(world: World) {
// step functions perform the assertions.
let _ = world;
}

/// Guards against threshold drift between the canonical constant and the UI
/// test configuration. The `ui/dylint.toml` threshold must equal
/// [`DEFAULT_THRESHOLD`] so fixtures exercise the production default.
#[rstest]
fn ui_dylint_toml_threshold_matches_default() {
let config_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("ui/dylint.toml");
let contents = std::fs::read_to_string(&config_path)
.unwrap_or_else(|err| panic!("failed to read {config_path:?}: {err}"));
let table: toml::Value =
toml::from_str(&contents).expect("ui/dylint.toml should parse as TOML");

let threshold = table
.get("bumpy_road_function")
.and_then(|section| section.get("threshold"))
.and_then(toml::Value::as_float)
.expect("ui/dylint.toml should contain [bumpy_road_function].threshold");

assert_eq!(
threshold, DEFAULT_THRESHOLD,
concat!(
"ui/dylint.toml threshold must equal DEFAULT_THRESHOLD; ",
"update the config file or the constant to keep them in sync"
)
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -31,5 +31,5 @@ Feature: Detect bumpy road intervals
Given default settings
When the threshold is set to -1.0
And I normalise the settings
Then the threshold becomes 3.0
Then the threshold becomes 2.5

2 changes: 1 addition & 1 deletion crates/bumpy_road_function/ui/dylint.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[bumpy_road_function]
threshold = 3.0
threshold = 2.5
window = 3
min_bump_lines = 2
include_closures = false
Expand Down
71 changes: 71 additions & 0 deletions crates/bumpy_road_function/ui/fail_match_with_nested_if.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
//! UI fixture that should trigger the bumpy road lint.
//!
//! This variant uses a match expression with two arms, each containing a nested
//! conditional block. The two conditional clusters form separated bumps.

pub mod fixture {
//! Test fixture providing types and functions that exercise the bumpy road
//! lint for the fail_match_with_nested_if UI test.

use std::path::PathBuf;

/// Build configuration mode controlling validation strictness.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum Mode {
/// Run with debug assertions and relaxed key-length checks.
Debug,
/// Optimized production build with strict validation.
Release,
}

impl Mode {
pub(crate) fn is_debug(self) -> bool {
matches!(self, Self::Debug)
}
}

const MIN_LEN: usize = 64;

/// Reads key material from disk and applies mode-dependent validation.
///
/// The match arms each contain nested conditional blocks, producing two
/// separated complexity bumps.
///
/// ```ignore
/// key_from_file(Mode::Debug, true);
/// ```
pub fn key_from_file(mode: Mode, allow_fallback: bool) -> Result<Vec<u8>, String> {
let path = PathBuf::from("key");

match std::fs::read(&path) {
Ok(mut bytes) => {
let length = bytes.len();
if mode == Mode::Release && length < MIN_LEN {
bytes.fill(0);
return Err(format!(
"key at {} is too short ({length} < {MIN_LEN})",
path.display()
));
}
let result = bytes.clone();
bytes.fill(0);
Ok(result)
}
Err(error) => {
if mode.is_debug() || allow_fallback {
Ok(vec![0; MIN_LEN])
} else {
Err(format!(
"cannot read key from {}: {error}",
path.display()
))
}
}
}
}

#[cfg(any())]
fn dead_code_fixture_marker() {}
}

fn main() {}
29 changes: 29 additions & 0 deletions crates/bumpy_road_function/ui/fail_match_with_nested_if.stderr
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
warning: Multiple clusters of nested conditional logic in `key_from_file`.
--> $DIR/fail_match_with_nested_if.rs:37:12
|
LL | pub fn key_from_file(mode: Mode, allow_fallback: bool) -> Result<Vec<u8>, String> {
| _____________^^^^^^^^^^^^^______________________________________________________________-
| | _______________________________________________________________________________________|
| ||
LL | || let path = PathBuf::from("key");
LL | ||
LL | || match std::fs::read(&path) {
... ||
LL | || ));
LL | || }
| ||_- Complexity bump 2 spans 6 lines.
... |
LL | | ))
LL | | }
| |__- Complexity bump 1 spans 7 lines.
|
note: Detected 2 complexity bumps above the threshold 2.5.
--> $DIR/fail_match_with_nested_if.rs:37:12
|
LL | pub fn key_from_file(mode: Mode, allow_fallback: bool) -> Result<Vec<u8>, String> {
| ^^^^^^^^^^^^^
= help: Extract helper functions from the highlighted regions to reduce clustered complexity.
= note: `#[warn(bumpy_road_function)]` on by default

warning: 1 warning emitted

Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ LL | | && input != 1800
LL | | && input != 1900
| |__- Complexity bump 2 spans 4 lines.
|
note: Detected 2 complexity bumps above the threshold 3.
note: Detected 2 complexity bumps above the threshold 2.5.
--> $DIR/fail_two_clusters_legacy.rs:11:8
|
LL | pub fn bumpy(input: i32) -> i32 {
Expand Down
43 changes: 43 additions & 0 deletions crates/bumpy_road_function/ui/pass_single_match_cluster.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//! UI fixture that should *not* trigger the bumpy road lint.
//!
//! This example uses a match expression where all conditional complexity is
//! concentrated in a single arm, forming only one contiguous cluster.

mod fixture {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
//! Test fixture providing conditional logic helpers for the
//! pass_single_match_cluster UI test.

/// Returns `true` when `n` falls in the small eligible range,
/// i.e. positive, below ten, and neither five nor seven.
fn is_small_eligible(n: i32) -> bool {
n > 0 && n < 10 && n != 5 && n != 7
}

/// Categorises the input with a single cluster of conditional logic.
///
/// ```ignore
/// assert_eq!(categorise(42), "other");
/// ```
pub fn categorise(input: i32) -> &'static str {
match input {
0 => "zero",
n if is_small_eligible(n) => {
if n % 2 == 0 {
"small even"
} else {
"small odd"
}
}
_ => "other",
}
}

pub fn dead_code_fixture_marker() {}
}

fn main() {
let _ = fixture::categorise(0);
let _ = fixture::categorise(4);
let _ = fixture::categorise(99);
fixture::dead_code_fixture_marker();
}
13 changes: 7 additions & 6 deletions docs/adr-002-dylint-expect-attribute-macro.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ Proposed.

## Context and problem statement

Whitaker enforces project-specific Rust conventions using Dylint lint libraries.
These lints run outwith normal `cargo check` and `cargo test` workflows.
Whitaker enforces project-specific Rust conventions using Dylint lint
libraries. These lints run outwith normal `cargo check` and `cargo test`
workflows.

Occasionally, a lint must be suppressed for a narrow scope (for example, a
legacy call-site that cannot be refactored immediately, or an intentional
Expand All @@ -26,8 +27,8 @@ noise in normal builds because:
- `rustc` does not know Dylint-defined lint names during ordinary compilation,
so it can emit `unknown_lints` diagnostics.
- The recommended Dylint gating mechanism uses `cfg_attr(dylint_lib = "…", …)`,
but toolchains can emit `unexpected_cfgs` diagnostics for unknown cfg keys/values
when `check-cfg` validation is enabled.
but toolchains can emit `unexpected_cfgs` diagnostics for unknown cfg
keys/values when `check-cfg` validation is enabled.

The project needs an ergonomic, consistent, and low-friction mechanism for
annotating items with conditional Dylint `expect` semantics that:
Expand Down Expand Up @@ -194,8 +195,8 @@ time.
area.
- Pre-expansion lints can bypass `cfg_attr` gating. For example, a
`#[derive(...)]` macro can raise lint diagnostics on generated code before
`dylint_expect` expansion is applied.
The macro cannot correct toolchain ordering constraints.
`dylint_expect` expansion is applied. The macro cannot correct toolchain
ordering constraints.
- The `lib` value must match the identifier Dylint injects via `dylint_lib`.
Mismatches silently disable the `expect` and can lead to missed enforcement.

Expand Down
89 changes: 89 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,95 @@ the regression suite: `temp-env` provides scoped environment overrides,
`tempfile` provides isolated target directories, and `rstest` powers the
fixture-based test setup used by the staged-suite coverage.

### Configuration constant patterns

Lint crates that expose configurable thresholds or defaults should centralize
the default value as a public constant. This prevents drift between code,
tests, configuration files, and documentation.

**Pattern:** Define a public constant in the analysis module and reference it
from `Settings::default()`, configuration parsing tests, and documentation.

**Example** (`bumpy_road_function`):

```rust
// src/analysis.rs
pub const DEFAULT_THRESHOLD: f64 = 2.5;

impl Default for Settings {
fn default() -> Self {
Self {
threshold: DEFAULT_THRESHOLD,
// ... other fields
}
}
}
```

Add a regression test that validates the UI test configuration matches the
constant:

```rust
// tests/analysis_behaviour.rs
#[test]
fn ui_dylint_toml_threshold_matches_default() {
let toml_path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("ui/dylint.toml");
let contents = fs::read_to_string(&toml_path)
.expect("ui/dylint.toml should exist");
let parsed: toml::Value = toml::from_str(&contents)
.expect("ui/dylint.toml should parse");

let threshold = parsed
.get("bumpy_road_function")
.and_then(|t| t.get("threshold"))
.and_then(toml::Value::as_float)
.expect("bumpy_road_function.threshold should be a float");

assert_eq!(
threshold, DEFAULT_THRESHOLD,
"ui/dylint.toml threshold must match DEFAULT_THRESHOLD constant"
);
}
```

### Nextest filter validation

When adding nextest test-group filters (e.g., for serializing UI tests), add a
regression test that verifies the filter expression captures all intended test
binaries.

**Pattern:** Create a test that discovers relevant test files, parses the
nextest config, and asserts the filter contains the necessary clauses.

**Example** (`tests/nextest_ui_filter.rs`):

```rust
use rstest::{fixture, rstest};

#[fixture]
fn serial_dylint_ui_override() -> Value {
let config = load_nextest_config();
find_serial_dylint_ui_override(&config).clone()
}

#[rstest]
fn serial_dylint_ui_filter_captures_integration_ui_binaries(
serial_dylint_ui_override: Value
) {
let filter = extract_filter(&serial_dylint_ui_override);
let crates = crates_with_integration_ui_test();

assert!(
filter.contains("(binary(ui) & test(=ui))"),
"filter must capture integration test binaries: {crates:?}"
);
}
```

This pattern prevents CI flakes where a new UI test is excluded from
serialization due to an incomplete filter expression.

## Using whitaker-installer

The `whitaker-installer` command-line interface (CLI) builds, links, and stages
Expand Down
Loading
Loading