From 6d025973d47bc3b05b168fcce7c97d5777910f99 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 29 Aug 2026 00:55:17 +0200 Subject: [PATCH 01/32] Correct en-GB-oxendict spellings in code symbols and prose Rename code symbols and prose to Oxford spelling (-ize/-yze for the -ise pairs; analyse-family words correctly keep -yse): normalize, localize, summarize, recognized, serialize, and the SARIF domain terms Artifact -> Artefact across whitaker_sarif and its consumers. The SARIF 2.1.0 wire format spells its properties `artifactLocation` and `artifacts`, so those fields carry explicit `#[serde(rename)]` pins with doc comments explaining that the wire name is fixed by the schema rather than derived from the field name. External identifiers (`CompilerArtifact`, cargo metadata types, GitHub Actions flags) keep the external system's spelling. Feature files and step definitions are renamed together, and the typos configuration is pruned from 255 ignore patterns to the residual set the corrected tree still needs, so the spelling gate now enforces the convention. A handful of self-named module files move to mod.rs form here because the renames were entangled with those moves. --- .config/nextest.toml | 4 +- Cargo.toml | 1 + common/locales/cy/common.ftl | 2 +- .../locales/en-GB/no_expect_outside_tests.ftl | 2 +- common/src/brain_trait_metrics/tests.rs | 4 +- common/src/complexity_signal.rs | 9 - common/src/i18n/mod.rs | 2 +- common/src/i18n/selection.rs | 8 +- common/src/lib.rs | 2 +- crates/bumpy_road_function/src/analysis.rs | 20 +- .../bumpy_road_function/src/driver/config.rs | 2 +- .../src/driver/diagnostic.rs | 17 +- .../src/{driver.rs => driver/mod.rs} | 6 +- .../src/driver/segment_builder.rs | 15 +- .../tests/analysis_behaviour.rs | 8 +- .../conditional_max_n_branches/src/driver.rs | 46 ++-- .../function_attrs_follow_docs/src/driver.rs | 2 +- .../src/tests/localization.rs | 6 +- crates/module_max_lines/Cargo.toml | 1 + crates/module_max_lines/src/driver.rs | 1 + crates/module_max_lines/src/lib.rs | 5 + .../src/{driver.rs => driver/mod.rs} | 8 +- ...il_expect_in_rstest_non_test_module.stderr | 2 +- .../fail_expect_in_tokio_crate_non_test_fn.rs | 2 +- ...l_expect_in_tokio_crate_non_test_fn.stderr | 2 +- .../no_expect_outside_tests/src/behaviour.rs | 8 +- .../src/{context.rs => context/mod.rs} | 4 +- .../src/diagnostics.rs | 4 +- .../no_expect_outside_tests/src/driver/mod.rs | 4 +- crates/no_expect_outside_tests/src/tests.rs | 14 +- .../src/tests/localization.rs | 4 +- .../src/tests/receiver_type_edge_cases.rs | 4 +- .../tests/features/context_summary.feature | 10 +- ...l_expect_in_file_backed_non_test_fn.stderr | 2 +- .../ui/fail_expect_in_fn.stderr | 4 +- .../ui/fail_expect_with_cfg_attr.stderr | 2 +- crates/no_std_fs_operations/Cargo.toml | 1 + crates/no_std_fs_operations/src/behaviour.rs | 10 +- .../no_std_fs_operations/src/diagnostics.rs | 2 +- .../src/exclusion_behaviour.rs | 1 + .../src/{usage.rs => usage/mod.rs} | 0 .../no_std_fs_operations/src/usage/tests.rs | 2 +- .../tests/features/localization.feature | 12 +- .../fail_unwrap_in_rstest_non_test_module.rs | 2 +- .../src/{context.rs => context/mod.rs} | 2 +- crates/no_unwrap_or_else_panic/src/driver.rs | 6 +- crates/test_must_not_have_example/Cargo.toml | 1 + .../src/behaviour.rs | 1 + .../whitaker_clones_core/src/index/tests.rs | 4 + crates/whitaker_clones_core/src/run0/emit.rs | 2 +- crates/whitaker_clones_core/src/run0/tests.rs | 4 +- crates/whitaker_clones_core/src/run0/types.rs | 2 +- .../tests/run0_sarif_behaviour.rs | 2 +- .../src/builders/location_builder.rs | 14 +- .../src/builders/log_builder.rs | 2 +- .../src/builders/run_builder.rs | 22 +- crates/whitaker_sarif/src/lib.rs | 2 +- crates/whitaker_sarif/src/merge.rs | 10 +- crates/whitaker_sarif/src/model/location.rs | 36 +-- crates/whitaker_sarif/src/model/mod.rs | 8 +- crates/whitaker_sarif/src/model/run.rs | 43 ++-- docs/developers-guide.md | 4 +- .../2-2-5-test-must-not-have-example-lint.md | 2 +- .../3-4-4-install-prebuilt-artefacts.md | 2 +- ...4.6. Record download-versus-build rates.md | 2 +- docs/execplans/7-1-1-whitaker-sarif-crate.md | 4 +- ...ication-of-bounded-lsh-index-invariants.md | 2 +- ...e-spans-and-extract-ast-feature-vectors.md | 4 +- ...2-1-create-the-rstest-helper-lint-crate.md | 2 +- ...-2-call-site-collection-in-rstest-tests.md | 4 +- .../installer-does-not-install-toolchain.md | 2 +- ...e-110-ambiguous-name-reported-by-dylint.md | 2 +- .../issue-94-dual-ownership-of-binary.md | 2 +- ...n-of-github-actions-with-act-and-pytest.md | 20 +- docs/rstest-bdd-users-guide.md | 2 +- docs/scripting-standards.md | 2 +- docs/whitaker-clone-detector-design.md | 2 +- docs/whitaker-dylint-suite-design.md | 8 +- installer/src/artefact/extraction.rs | 8 +- installer/src/artefact/schema_version.rs | 2 +- installer/src/artefact/sha256_digest.rs | 1 + .../dependency_binaries/install/checksum.rs | 7 +- .../dependency_binaries/install/downloader.rs | 7 +- .../install/http_test_server.rs | 62 ++++-- installer/src/{deps.rs => deps/mod.rs} | 0 installer/src/output.rs | 3 +- src/config.rs | 10 +- src/lints/template/validation.rs | 12 +- tests/config_loading.rs | 4 +- tests/locale_resolution.rs | 5 +- tests/ui_harness.rs | 1 + typos.local.toml | 209 +----------------- 92 files changed, 344 insertions(+), 481 deletions(-) rename crates/bumpy_road_function/src/{driver.rs => driver/mod.rs} (97%) rename crates/module_must_have_inner_docs/src/{driver.rs => driver/mod.rs} (98%) rename crates/no_expect_outside_tests/src/{context.rs => context/mod.rs} (99%) rename crates/no_std_fs_operations/src/{usage.rs => usage/mod.rs} (100%) rename crates/no_unwrap_or_else_panic/src/{context.rs => context/mod.rs} (99%) rename installer/src/{deps.rs => deps/mod.rs} (100%) diff --git a/.config/nextest.toml b/.config/nextest.toml index 845939141..295a4e315 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -68,7 +68,7 @@ filter = "binary(build_script_integration)" slow-timeout = { period = "30m", terminate-after = 1, grace-period = "5s" } test-group = "serial-build-script-integration" -# Serialise dylint UI tests that build lint libraries and use +# Serialize dylint UI tests that build lint libraries and use # `#[serial]`. nextest runs each test in its own process, so the # `serial_test` crate's in-process mutex has no effect. A test-group # with max-threads = 1 prevents concurrent lint-library builds from @@ -132,7 +132,7 @@ slow-timeout = { period = "30m", terminate-after = 1, grace-period = "5s" } test-group = "serial-build-script-integration" [[profile.default.overrides]] -# Serialise ignored exclusion integration tests when they are explicitly run. +# Serialize ignored exclusion integration tests when they are explicitly run. # They build and stage the lint library before invoking `cargo dylint`, so they # share the same target-directory race risk as the UI harnesses above. filter = "binary(integration_exclusion)" diff --git a/Cargo.toml b/Cargo.toml index 1e79d94ef..184e118be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -117,6 +117,7 @@ rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } dylint_testing = { workspace = true } +whitaker_test_macros = { workspace = true } [lints.clippy] pedantic = { level = "warn", priority = -1 } diff --git a/common/locales/cy/common.ftl b/common/locales/cy/common.ftl index d5f35a3f0..4f996c64d 100644 --- a/common/locales/cy/common.ftl +++ b/common/locales/cy/common.ftl @@ -6,7 +6,7 @@ -term-branch = cangen -term-test-coverage = cwmpas profion -# Borrowed English nouns typically pluralise with -iau (Modern Welsh, Gareth +# Borrowed English nouns typically pluralize with -iau (Modern Welsh, Gareth # King ยง2.8), so we render "lint" as "lintiau" in aggregated messaging. common-lint-count = { $lint -> diff --git a/common/locales/en-GB/no_expect_outside_tests.ftl b/common/locales/en-GB/no_expect_outside_tests.ftl index 29ef45aa1..3d478e5f8 100644 --- a/common/locales/en-GB/no_expect_outside_tests.ftl +++ b/common/locales/en-GB/no_expect_outside_tests.ftl @@ -1,7 +1,7 @@ ## Restrict expect calls outside test contexts. no_expect_outside_tests = Avoid calling expect on { $receiver } outside test-only code. - .note = The call originates within { $context } which is not recognised as a test. + .note = The call originates within { $context } which is not recognized as a test. .help = { $handling -> [option] Handle the `None` variant of { $receiver } or move the code into a test. [result] Handle the `Err` variant of { $receiver } or move the code into a test. diff --git a/common/src/brain_trait_metrics/tests.rs b/common/src/brain_trait_metrics/tests.rs index e5e03328c..e0e5965b2 100644 --- a/common/src/brain_trait_metrics/tests.rs +++ b/common/src/brain_trait_metrics/tests.rs @@ -96,7 +96,7 @@ fn default_method_cc_sum_aggregates_only_default_methods() { let items = vec![ TraitItemMetrics::required_method("parse"), TraitItemMetrics::default_method("render", 12), - TraitItemMetrics::default_method("serialise", 8), + TraitItemMetrics::default_method("serialize", 8), TraitItemMetrics::associated_type("Output"), ]; @@ -191,7 +191,7 @@ fn implementor_burden_equals_required_method_count() { let mut builder = TraitMetricsBuilder::new("Transformer"); builder.add_required_method("parse"); builder.add_required_method("validate"); - builder.add_default_method("normalise", 7, false); + builder.add_default_method("normalize", 7, false); builder.add_associated_type("Output"); let metrics = builder.build(); diff --git a/common/src/complexity_signal.rs b/common/src/complexity_signal.rs index d71e903c6..29d6caffd 100644 --- a/common/src/complexity_signal.rs +++ b/common/src/complexity_signal.rs @@ -225,15 +225,6 @@ pub fn rasterize_signal( Ok(accumulate_signal_from_diff(diff.as_slice(), len)) } -#[deprecated(note = "Use rasterize_signal instead.")] -#[must_use = "Inspect the signal build result to handle invalid ranges"] -pub fn rasterise_signal( - function_lines: RangeInclusive, - segments: &[LineSegment], -) -> Result, SignalBuildError> { - rasterize_signal(function_lines, segments) -} - /// Errors emitted when smoothing a signal. #[derive(Clone, Copy, Debug, Error, PartialEq, Eq)] pub enum SmoothingError { diff --git a/common/src/i18n/mod.rs b/common/src/i18n/mod.rs index 383a426c2..300db0588 100644 --- a/common/src/i18n/mod.rs +++ b/common/src/i18n/mod.rs @@ -78,7 +78,7 @@ pub use helpers::{ }; pub use loader::{Arguments, I18nError, Localizer}; pub use locales::{available_locales, supports_locale}; -pub use selection::{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer}; +pub use selection::{LocaleSelection, LocaleSource, normalize_locale, resolve_localizer}; #[cfg(test)] mod tests; diff --git a/common/src/i18n/selection.rs b/common/src/i18n/selection.rs index ce7b02434..dbea901b2 100644 --- a/common/src/i18n/selection.rs +++ b/common/src/i18n/selection.rs @@ -97,7 +97,7 @@ impl LocaleSelection { /// Attempt to resolve a locale candidate from the given source. fn try_resolve_candidate(source: LocaleSource, raw: Option<&str>) -> Option { - let candidate = normalise_locale(raw)?; + let candidate = normalize_locale(raw)?; if supports_locale(candidate) { return Some(LocaleSelection::new( @@ -143,7 +143,7 @@ pub fn resolve_localizer( /// Trim whitespace and discard empty locale candidates. #[must_use] -pub fn normalise_locale(input: Option<&str>) -> Option<&str> { +pub fn normalize_locale(input: Option<&str>) -> Option<&str> { input.map(str::trim).filter(|value| !value.is_empty()) } @@ -223,7 +223,7 @@ mod tests { #[case(Some(" "), None)] #[case(Some("cy"), Some("cy"))] #[case(Some(" cy "), Some("cy"))] - fn normalises_candidates(#[case] input: Option<&str>, #[case] expected: Option<&str>) { - assert_eq!(normalise_locale(input), expected); + fn normalizes_candidates(#[case] input: Option<&str>, #[case] expected: Option<&str>) { + assert_eq!(normalize_locale(input), expected); } } diff --git a/common/src/lib.rs b/common/src/lib.rs index bb7214a0e..bf7007b63 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -50,7 +50,7 @@ pub use expr::{Expr, def_id_of_expr_callee, is_path_to, recv_is_option_or_result pub use i18n::{ Arguments, FALLBACK_LOCALE, I18nError, LocaleSelection, LocaleSource, Localizer, MessageResolution, available_locales, branch_phrase, get_localizer_for_lint, noop_reporter, - normalise_locale, resolve_localizer, safe_resolve_message_set, supports_locale, + normalize_locale, resolve_localizer, safe_resolve_message_set, supports_locale, }; pub use lcom4::{MethodInfo, MethodInfoBuilder, cohesion_components, collect_method_infos}; pub use path::SimplePath; diff --git a/crates/bumpy_road_function/src/analysis.rs b/crates/bumpy_road_function/src/analysis.rs index 72aa96d0c..15e9401b9 100644 --- a/crates/bumpy_road_function/src/analysis.rs +++ b/crates/bumpy_road_function/src/analysis.rs @@ -69,7 +69,7 @@ impl Default for Settings { /// # Examples /// /// ``` -/// use bumpy_road_function::analysis::{normalise_settings, Settings, Weights}; +/// use bumpy_road_function::analysis::{normalize_settings, Settings, Weights}; /// /// let settings = Settings { /// threshold: -1.0, @@ -82,14 +82,14 @@ impl Default for Settings { /// ..Settings::default() /// }; /// -/// let normalised = normalise_settings(settings); -/// assert_eq!(normalised.threshold, Settings::default().threshold); -/// assert_eq!(normalised.window, Settings::default().window); -/// assert_eq!(normalised.weights, Settings::default().weights); +/// let normalized = normalize_settings(settings); +/// assert_eq!(normalized.threshold, Settings::default().threshold); +/// assert_eq!(normalized.window, Settings::default().window); +/// assert_eq!(normalized.weights, Settings::default().weights); /// ``` #[must_use] -pub fn normalise_settings(settings: Settings) -> Settings { - fn normalise_weight(candidate: f64, fallback: f64) -> f64 { +pub fn normalize_settings(settings: Settings) -> Settings { + fn normalize_weight(candidate: f64, fallback: f64) -> f64 { if candidate.is_finite() && candidate >= 0.0 { candidate } else { @@ -116,9 +116,9 @@ pub fn normalise_settings(settings: Settings) -> Settings { let min_bump_lines = settings.min_bump_lines.max(1); let weights = Weights { - depth: normalise_weight(settings.weights.depth, defaults.weights.depth), - predicate: normalise_weight(settings.weights.predicate, defaults.weights.predicate), - flow: normalise_weight(settings.weights.flow, defaults.weights.flow), + depth: normalize_weight(settings.weights.depth, defaults.weights.depth), + predicate: normalize_weight(settings.weights.predicate, defaults.weights.predicate), + flow: normalize_weight(settings.weights.flow, defaults.weights.flow), }; Settings { diff --git a/crates/bumpy_road_function/src/driver/config.rs b/crates/bumpy_road_function/src/driver/config.rs index 4381ab7a9..4388791bb 100644 --- a/crates/bumpy_road_function/src/driver/config.rs +++ b/crates/bumpy_road_function/src/driver/config.rs @@ -1,7 +1,7 @@ //! Configuration parsing and loading for the bumpy road lint. //! //! The lint reads optional configuration from `dylint.toml`, applies defaults, -//! and relies on `analysis::normalise_settings` to clamp invalid values. +//! and relies on `analysis::normalize_settings` to clamp invalid values. use crate::analysis::{Settings, Weights}; use log::debug; diff --git a/crates/bumpy_road_function/src/driver/diagnostic.rs b/crates/bumpy_road_function/src/driver/diagnostic.rs index 467e43263..265065111 100644 --- a/crates/bumpy_road_function/src/driver/diagnostic.rs +++ b/crates/bumpy_road_function/src/driver/diagnostic.rs @@ -36,7 +36,7 @@ pub(super) fn emit_diagnostic( args.insert(Cow::Borrowed("name"), FluentValue::from(input.name)); args.insert( Cow::Borrowed("count"), - FluentValue::from(input.bumps.len() as i64), + FluentValue::from(i64::try_from(input.bumps.len()).unwrap_or(i64::MAX)), ); args.insert( Cow::Borrowed("threshold"), @@ -66,8 +66,11 @@ pub(super) fn emit_diagnostic( let Some(span) = bump_spans.get(ordinal).copied().flatten() else { continue; }; - let label = - resolve_bump_label(localizer, (ordinal + 1) as i64, interval.len() as i64); + let label = resolve_bump_label( + localizer, + i64::try_from(ordinal + 1).unwrap_or(i64::MAX), + i64::try_from(interval.len()).unwrap_or(i64::MAX), + ); lint.span_label(span, label); } @@ -150,10 +153,10 @@ impl LineSpanMapper { .unwrap_or(self.snippet_len); let base = self.base_span.shrink_to_lo(); - // `BytePos` is `u32`-backed; the snippet length is expected to fit in - // 4 GiB for any reasonable Rust source file. - let lo = base.lo() + BytePos(start_offset as u32); - let mut hi = base.lo() + BytePos(end_offset as u32); + // `BytePos` is `u32`-backed; offsets beyond 4 GiB cannot be represented + // as spans, so such (pathological) snippets yield no span. + let lo = base.lo() + BytePos(u32::try_from(start_offset).ok()?); + let mut hi = base.lo() + BytePos(u32::try_from(end_offset).ok()?); if hi <= lo { hi = lo + BytePos(1); } diff --git a/crates/bumpy_road_function/src/driver.rs b/crates/bumpy_road_function/src/driver/mod.rs similarity index 97% rename from crates/bumpy_road_function/src/driver.rs rename to crates/bumpy_road_function/src/driver/mod.rs index 41ab1c795..e3afd6857 100644 --- a/crates/bumpy_road_function/src/driver.rs +++ b/crates/bumpy_road_function/src/driver/mod.rs @@ -5,7 +5,7 @@ //! more separated bumps above a configurable threshold. The warning highlights //! the two largest bump intervals with labelled spans. -use crate::analysis::{Settings, detect_bumps, normalise_settings}; +use crate::analysis::{Settings, detect_bumps, normalize_settings}; use rustc_hir as hir; use rustc_hir::ExprKind; use rustc_lint::{LateContext, LateLintPass}; @@ -52,7 +52,7 @@ impl Default for BumpyRoadFunction { impl<'tcx> LateLintPass<'tcx> for BumpyRoadFunction { fn check_crate(&mut self, _cx: &LateContext<'tcx>) { - self.settings = normalise_settings(load_configuration().into_settings()); + self.settings = normalize_settings(load_configuration().into_settings()); let shared_config = SharedConfig::load(); self.localizer = get_localizer_for_lint(LINT_NAME, shared_config.locale()); } @@ -196,7 +196,7 @@ fn analyse_body( Err(error) => { cx.tcx.sess.dcx().span_delayed_bug( body_span, - format!("bumpy-road signal rasterisation failed: {error}"), + format!("bumpy-road signal rasterization failed: {error}"), ); return; } diff --git a/crates/bumpy_road_function/src/driver/segment_builder.rs b/crates/bumpy_road_function/src/driver/segment_builder.rs index 27992e200..2b7f7ee44 100644 --- a/crates/bumpy_road_function/src/driver/segment_builder.rs +++ b/crates/bumpy_road_function/src/driver/segment_builder.rs @@ -139,7 +139,7 @@ impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { return; } - let branches = count_branches(expr) as f64; + let branches = f64::from(u32::try_from(count_branches(expr)).unwrap_or(u32::MAX)); let value = branches * self.settings.weights.predicate; self.push_segment(expr.span, value); } @@ -211,12 +211,8 @@ fn count_branches(expr: &hir::Expr<'_>) -> usize { ExprKind::Binary(op, lhs, rhs) if matches!(op.node, BinOpKind::And | BinOpKind::Or) => { count_branches(lhs) + count_branches(rhs) } - ExprKind::Unary(UnOp::Not, inner) => count_branches(inner), - ExprKind::DropTemps(inner) => count_branches(inner), - ExprKind::Block(block, _) => match block.expr { - Some(inner) => count_branches(inner), - None => 1, - }, + ExprKind::Unary(UnOp::Not, inner) | ExprKind::DropTemps(inner) => count_branches(inner), + ExprKind::Block(block, _) => block.expr.map_or(1, count_branches), ExprKind::If(cond, ..) => count_branches(cond), _ => 1, } @@ -229,8 +225,9 @@ pub(super) fn span_line_range(source_map: &SourceMap, span: Span) -> Option LateLintPass<'tcx> for ConditionalMaxNBranches { fn check_crate(&mut self, _cx: &LateContext<'tcx>) { self.max_branches = load_configuration().max_branches.max(1); @@ -187,12 +200,8 @@ fn count_branches(expr: &hir::Expr<'_>) -> usize { ExprKind::Binary(op, lhs, rhs) if matches!(op.node, BinOpKind::And | BinOpKind::Or) => { count_branches(lhs) + count_branches(rhs) } - ExprKind::Unary(UnOp::Not, inner) => count_branches(inner), - ExprKind::DropTemps(inner) => count_branches(inner), - ExprKind::Block(block, _) => match block.expr { - Some(inner) => count_branches(inner), - None => 1, - }, + ExprKind::Unary(UnOp::Not, inner) | ExprKind::DropTemps(inner) => count_branches(inner), + ExprKind::Block(block, _) => block.expr.map_or(1, count_branches), ExprKind::If(cond, ..) => count_branches(cond), _ => 1, } @@ -211,9 +220,12 @@ fn emit_diagnostic( ); args.insert( Cow::Borrowed("branches"), - FluentValue::from(metadata.branches as i64), + FluentValue::from(i64::try_from(metadata.branches).unwrap_or(i64::MAX)), + ); + args.insert( + Cow::Borrowed("limit"), + FluentValue::from(i64::try_from(limit).unwrap_or(i64::MAX)), ); - args.insert(Cow::Borrowed("limit"), FluentValue::from(limit as i64)); let branch_phrase_text = branch_phrase(localizer.locale(), metadata.branches); args.insert( Cow::Borrowed("branch_phrase"), @@ -234,9 +246,9 @@ fn emit_diagnostic( fallback_messages(metadata.kind, metadata.branches, limit) }); - let primary = normalise_isolation_marks(messages.primary()); - let note = normalise_isolation_marks(messages.note()); - let help = normalise_isolation_marks(messages.help()); + let primary = normalize_isolation_marks(messages.primary()); + let note = normalize_isolation_marks(messages.note()); + let help = normalize_isolation_marks(messages.help()); cx.emit_span_lint( CONDITIONAL_MAX_N_BRANCHES, @@ -249,7 +261,7 @@ fn emit_diagnostic( ); } -fn normalise_isolation_marks(text: &str) -> String { +fn normalize_isolation_marks(text: &str) -> String { if text .chars() .any(|character| matches!(character, '\u{2068}' | '\u{2069}' | '\u{FFFD}')) diff --git a/crates/function_attrs_follow_docs/src/driver.rs b/crates/function_attrs_follow_docs/src/driver.rs index 1235e3d4b..9f5ef801a 100644 --- a/crates/function_attrs_follow_docs/src/driver.rs +++ b/crates/function_attrs_follow_docs/src/driver.rs @@ -332,7 +332,7 @@ const MESSAGE_KEY: MessageKey<'static> = MessageKey::new("function_attrs_follow_ type FunctionAttrsMessages = DiagnosticMessageSet; #[cfg(test)] -fn localised_messages( +fn localized_messages( lookup: &impl BundleLookup, kind: FunctionKind, attribute: &str, diff --git a/crates/function_attrs_follow_docs/src/tests/localization.rs b/crates/function_attrs_follow_docs/src/tests/localization.rs index 7afb76f67..45d24741a 100644 --- a/crates/function_attrs_follow_docs/src/tests/localization.rs +++ b/crates/function_attrs_follow_docs/src/tests/localization.rs @@ -11,7 +11,7 @@ use whitaker_common::i18n::{I18nError, testing::FailingLookup}; use super::{ FunctionAttrsMessages, FunctionKind, Localizer, MESSAGE_KEY, attribute_fallback, - localised_messages, + localized_messages, }; #[derive(Default)] @@ -119,9 +119,9 @@ fn resolve_localization( ) -> Result { if failing { let lookup = failing_lookup(); - localised_messages(&lookup, kind, attribute) + localized_messages(&lookup, kind, attribute) } else { - world.with_localizer(|localizer| localised_messages(localizer, kind, attribute)) + world.with_localizer(|localizer| localized_messages(localizer, kind, attribute)) } } diff --git a/crates/module_max_lines/Cargo.toml b/crates/module_max_lines/Cargo.toml index d4575d288..7de87a660 100644 --- a/crates/module_max_lines/Cargo.toml +++ b/crates/module_max_lines/Cargo.toml @@ -34,6 +34,7 @@ whitaker = { workspace = true, features = ["dylint-driver"], optional = true } fluent-templates = { workspace = true, optional = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } camino = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } diff --git a/crates/module_max_lines/src/driver.rs b/crates/module_max_lines/src/driver.rs index c67dd8522..3eda99591 100644 --- a/crates/module_max_lines/src/driver.rs +++ b/crates/module_max_lines/src/driver.rs @@ -250,6 +250,7 @@ mod behaviour { } } + #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn world() -> ModuleWorld { ModuleWorld::default() diff --git a/crates/module_max_lines/src/lib.rs b/crates/module_max_lines/src/lib.rs index 3fbf5a1a9..840470ad6 100644 --- a/crates/module_max_lines/src/lib.rs +++ b/crates/module_max_lines/src/lib.rs @@ -1,3 +1,8 @@ +//! Dylint lint that flags modules exceeding the configured line budget. +//! +//! The lint drives contributors toward smaller, reviewable modules; the +//! `dylint-driver` feature gates the rustc-facing implementation so the crate +//! also builds as an ordinary library. #![cfg_attr(feature = "dylint-driver", feature(rustc_private))] #[cfg(feature = "dylint-driver")] diff --git a/crates/module_must_have_inner_docs/src/driver.rs b/crates/module_must_have_inner_docs/src/driver/mod.rs similarity index 98% rename from crates/module_must_have_inner_docs/src/driver.rs rename to crates/module_must_have_inner_docs/src/driver/mod.rs index 2bba08980..ef19d43ba 100644 --- a/crates/module_must_have_inner_docs/src/driver.rs +++ b/crates/module_must_have_inner_docs/src/driver/mod.rs @@ -326,17 +326,17 @@ fn fallback_messages(module: ModuleName<'_>) -> ModuleDocMessages { } #[cfg(test)] -#[path = "tests/behaviour.rs"] +#[path = "../tests/behaviour.rs"] mod behaviour; #[cfg(test)] -#[path = "tests/ui.rs"] +#[path = "../tests/ui.rs"] mod ui; #[cfg(test)] -#[path = "tests/classifier.rs"] +#[path = "../tests/classifier.rs"] mod classifier; #[cfg(test)] -#[path = "tests/span_to_snippet.rs"] +#[path = "../tests/span_to_snippet.rs"] mod span_to_snippet; diff --git a/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.stderr b/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.stderr index 17b1568df..a9beb0365 100644 --- a/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.stderr +++ b/crates/no_expect_outside_tests/examples/fail_expect_in_rstest_non_test_module.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option<&str>` outside test-only cod LL | let _ = parsed.expect("ordinary code must not inherit rstest harness status"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `parse` which is not recognised as a test. + = note: The call originates within function `parse` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option<&str>` or move the code into a test. = note: requested on the command line with `-D no-expect-outside-tests` diff --git a/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.rs b/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.rs index 4da9179f3..97f6f537d 100644 --- a/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.rs +++ b/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.rs @@ -1,7 +1,7 @@ //! Negative regression ensuring Tokio-specific test configuration does not //! leak into ordinary code. //! -//! The fixture config recognises `#[tokio::test]`, but `parse_config` remains +//! The fixture config recognizes `#[tokio::test]`, but `parse_config` remains //! production code and must still trigger the lint even in the same crate. fn parse_config() { diff --git a/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.stderr b/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.stderr index add3a1f4c..1aece484b 100644 --- a/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.stderr +++ b/crates/no_expect_outside_tests/examples/fail_expect_in_tokio_crate_non_test_fn.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option<&str>` outside test-only cod LL | let _ = parsed.expect("ordinary code in a Tokio crate must still lint"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `parse_config` which is not recognised as a test. + = note: The call originates within function `parse_config` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option<&str>` or move the code into a test. = note: requested on the command line with `-D no-expect-outside-tests` diff --git a/crates/no_expect_outside_tests/src/behaviour.rs b/crates/no_expect_outside_tests/src/behaviour.rs index 2c94e0084..1f903e09a 100644 --- a/crates/no_expect_outside_tests/src/behaviour.rs +++ b/crates/no_expect_outside_tests/src/behaviour.rs @@ -1,7 +1,7 @@ //! Behaviour-driven tests covering context summarization for the lint's context //! world and BDD steps. -use crate::context::{ContextSummary, summarise_context}; +use crate::context::{ContextSummary, summarize_context}; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use std::cell::RefCell; @@ -55,7 +55,7 @@ impl ContextWorld { fn evaluate(&self) { let entries = self.entries.borrow(); - let summary = summarise_context( + let summary = summarize_context( entries.as_slice(), *self.cfg_test.borrow(), self.additional.borrow().as_slice(), @@ -115,8 +115,8 @@ fn given_doctest(world: &ContextWorld) { world.mark_doctest(); } -#[when("I summarise the context")] -fn when_summarise(world: &ContextWorld) { +#[when("I summarize the context")] +fn when_summarize(world: &ContextWorld) { world.evaluate(); } diff --git a/crates/no_expect_outside_tests/src/context.rs b/crates/no_expect_outside_tests/src/context/mod.rs similarity index 99% rename from crates/no_expect_outside_tests/src/context.rs rename to crates/no_expect_outside_tests/src/context/mod.rs index 5a03d4c51..fd29d750c 100644 --- a/crates/no_expect_outside_tests/src/context.rs +++ b/crates/no_expect_outside_tests/src/context/mod.rs @@ -110,7 +110,7 @@ fn has_test_ancestry( /// # Examples /// /// ```ignore -/// let summary = summarise_context( +/// let summary = summarize_context( /// &entries, /// has_test_context_ancestry, /// additional_test_attributes, @@ -119,7 +119,7 @@ fn has_test_ancestry( /// // `.expect()` is allowed in this context. /// } /// ``` -pub(crate) fn summarise_context( +pub(crate) fn summarize_context( entries: &[ContextEntry], has_test_context_ancestry: bool, additional_test_attributes: &[AttributePath], diff --git a/crates/no_expect_outside_tests/src/diagnostics.rs b/crates/no_expect_outside_tests/src/diagnostics.rs index 48d582e5a..8f4535251 100644 --- a/crates/no_expect_outside_tests/src/diagnostics.rs +++ b/crates/no_expect_outside_tests/src/diagnostics.rs @@ -195,7 +195,7 @@ const MESSAGE_KEY: MessageKey<'static> = MessageKey::new("no_expect_outside_test type NoExpectMessages = DiagnosticMessageSet; #[cfg(test)] -fn localised_messages( +fn localized_messages( lookup: &impl BundleLookup, receiver: &ReceiverLabel, context: &ContextLabel, @@ -224,7 +224,7 @@ fn fallback_messages( category: ReceiverCategory, ) -> NoExpectMessages { let primary = format!("Avoid calling expect on {receiver} outside test-only code."); - let note = format!("The call originates within {context} which is not recognised as a test.",); + let note = format!("The call originates within {context} which is not recognized as a test.",); let help = category.fallback_help(receiver); NoExpectMessages::new(primary, note, help) diff --git a/crates/no_expect_outside_tests/src/driver/mod.rs b/crates/no_expect_outside_tests/src/driver/mod.rs index 25d1485d4..5ecdbfb9a 100644 --- a/crates/no_expect_outside_tests/src/driver/mod.rs +++ b/crates/no_expect_outside_tests/src/driver/mod.rs @@ -23,7 +23,7 @@ use whitaker::SharedConfig; use whitaker::hir::has_test_like_hir_attributes; use whitaker_common::{AttributePath, Localizer, get_localizer_for_lint}; -use crate::context::{collect_context, is_cfg_test_attribute, summarise_context}; +use crate::context::{collect_context, is_cfg_test_attribute, summarize_context}; use crate::diagnostics::{DiagnosticContext, emit_diagnostic}; dylint_linting::impl_late_lint! { @@ -122,7 +122,7 @@ impl<'tcx> LateLintPass<'tcx> for NoExpectOutsideTests { let additional = self.additional_test_attributes.as_slice(); let (entries, has_test_context_ancestry) = collect_context(cx, expr.hir_id, additional); - let summary = summarise_context(entries.as_slice(), has_test_context_ancestry, additional); + let summary = summarize_context(entries.as_slice(), has_test_context_ancestry, additional); if summary.is_test { return; diff --git a/crates/no_expect_outside_tests/src/tests.rs b/crates/no_expect_outside_tests/src/tests.rs index f569b8b4c..a74000480 100644 --- a/crates/no_expect_outside_tests/src/tests.rs +++ b/crates/no_expect_outside_tests/src/tests.rs @@ -1,7 +1,7 @@ //! Unit tests validating context summarization outcomes across default and //! configured test attributes. -use crate::context::summarise_context; +use crate::context::summarize_context; use rstest::rstest; use whitaker_common::attributes::{Attribute, AttributeKind, AttributePath}; use whitaker_common::{ContextEntry, ContextKind}; @@ -19,18 +19,18 @@ fn test_attribute() -> Attribute { } #[rstest] -fn summarises_plain_context() { +fn summarizes_plain_context() { let entries = vec![function_entry("handler", Vec::new())]; - let summary = summarise_context(&entries, false, &[]); + let summary = summarize_context(&entries, false, &[]); assert!(!summary.is_test); assert_eq!(summary.function_name.as_deref(), Some("handler")); } #[rstest] -fn recognises_test_attribute() { +fn recognizes_test_attribute() { let entries = vec![function_entry("test_case", vec![test_attribute()])]; - let summary = summarise_context(&entries, false, &[]); + let summary = summarize_context(&entries, false, &[]); assert!(summary.is_test); assert_eq!(summary.function_name.as_deref(), Some("test_case")); @@ -39,7 +39,7 @@ fn recognises_test_attribute() { #[rstest] fn honours_cfg_test() { let entries = vec![module_entry("tests", Vec::new())]; - let summary = summarise_context(&entries, true, &[]); + let summary = summarize_context(&entries, true, &[]); assert!(summary.is_test); assert_eq!(summary.function_name, None); @@ -55,7 +55,7 @@ fn honours_additional_attributes() { )], )]; let additional = vec![AttributePath::from("custom::test")]; - let summary = summarise_context(&entries, false, additional.as_slice()); + let summary = summarize_context(&entries, false, additional.as_slice()); assert!(summary.is_test); assert_eq!(summary.function_name.as_deref(), Some("custom")); diff --git a/crates/no_expect_outside_tests/src/tests/localization.rs b/crates/no_expect_outside_tests/src/tests/localization.rs index 5890b799f..3bfbc6ee1 100644 --- a/crates/no_expect_outside_tests/src/tests/localization.rs +++ b/crates/no_expect_outside_tests/src/tests/localization.rs @@ -13,7 +13,7 @@ use whitaker_common::i18n::{BundleLookup, testing::FailingLookup}; use super::{ I18nError, Localizer, MESSAGE_KEY, NoExpectMessages, ReceiverCategory, ReceiverLabel, - context_label, fallback_messages, localised_messages, + context_label, fallback_messages, localized_messages, }; use crate::context::ContextSummary; @@ -267,7 +267,7 @@ fn execute_localization( ) -> Result { let context = context_label(summary); let category = ReceiverCategory::for_label(receiver); - localised_messages(lookup, receiver, &context, category) + localized_messages(lookup, receiver, &context, category) } fn failing_lookup() -> FailingLookup { diff --git a/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs b/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs index b4d40b19f..769ab60af 100644 --- a/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs +++ b/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs @@ -3,7 +3,7 @@ use rstest::rstest; use super::{ - ContextLabel, Localizer, NoExpectMessages, ReceiverCategory, ReceiverLabel, localised_messages, + ContextLabel, Localizer, NoExpectMessages, ReceiverCategory, ReceiverLabel, localized_messages, }; #[rstest] @@ -27,7 +27,7 @@ fn handles_receiver_type_edge_cases( let receiver_label = ReceiverLabel::new(receiver); let context_label = ContextLabel::new(context); let category = ReceiverCategory::for_label(&receiver_label); - let messages = localised_messages(&lookup, &receiver_label, &context_label, category) + let messages = localized_messages(&lookup, &receiver_label, &context_label, category) .expect("localization succeeds"); assert!( assertion(&messages), diff --git a/crates/no_expect_outside_tests/tests/features/context_summary.feature b/crates/no_expect_outside_tests/tests/features/context_summary.feature index 7973fca8e..3a22aaa2f 100644 --- a/crates/no_expect_outside_tests/tests/features/context_summary.feature +++ b/crates/no_expect_outside_tests/tests/features/context_summary.feature @@ -2,32 +2,32 @@ Feature: Summarise traversal context for `.expect(..)` linting Scenario: Plain function without test attributes Given a non-test function named handler - When I summarise the context + When I summarize the context Then the context is marked as production And the function name is handler Scenario: Function marked as a test Given a test function named works - When I summarise the context + When I summarize the context Then the context is marked as test And the function name is works Scenario: Module guarded by cfg(test) Given a module with cfg(test) - When I summarise the context + When I summarize the context Then the context is marked as test And no function name is recorded Scenario: Function recognized via configured attribute Given an additional test attribute custom::test is configured And a function annotated with the additional attribute custom::test - When I summarise the context + When I summarize the context Then the context is marked as test And the function name is custom Scenario: Doctest crate bypasses linting Given a non-test function named handler And the lint is running within a doctest - When I summarise the context + When I summarize the context Then the lint is skipped And the function name is handler diff --git a/crates/no_expect_outside_tests/ui/fail_expect_in_file_backed_non_test_fn.stderr b/crates/no_expect_outside_tests/ui/fail_expect_in_file_backed_non_test_fn.stderr index 85082bd0d..2778af4f8 100644 --- a/crates/no_expect_outside_tests/ui/fail_expect_in_file_backed_non_test_fn.stderr +++ b/crates/no_expect_outside_tests/ui/fail_expect_in_file_backed_non_test_fn.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option<&str>` outside test-only cod LL | let _ = value.expect("file-backed cfg(test) ancestry must not leak into main"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `main` which is not recognised as a test. + = note: The call originates within function `main` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option<&str>` or move the code into a test. note: the lint level is defined here --> $DIR/fail_expect_in_file_backed_non_test_fn.rs:6:9 diff --git a/crates/no_expect_outside_tests/ui/fail_expect_in_fn.stderr b/crates/no_expect_outside_tests/ui/fail_expect_in_fn.stderr index 688473104..609da9dc5 100644 --- a/crates/no_expect_outside_tests/ui/fail_expect_in_fn.stderr +++ b/crates/no_expect_outside_tests/ui/fail_expect_in_fn.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option` outside test-only code LL | let _result = value.expect("value should exist"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `process` which is not recognised as a test. + = note: The call originates within function `process` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option` or move the code into a test. note: the lint level is defined here --> $DIR/fail_expect_in_fn.rs:2:9 @@ -18,7 +18,7 @@ error: Avoid calling expect on `std::result::Result<(), &str>` outside test-only LL | let _ = result.expect("result should be ok"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `fail_result` which is not recognised as a test. + = note: The call originates within function `fail_result` which is not recognized as a test. = help: Handle the `Err` variant of `std::result::Result<(), &str>` or move the code into a test. error: aborting due to 2 previous errors diff --git a/crates/no_expect_outside_tests/ui/fail_expect_with_cfg_attr.stderr b/crates/no_expect_outside_tests/ui/fail_expect_with_cfg_attr.stderr index e5d6c5184..aa650a667 100644 --- a/crates/no_expect_outside_tests/ui/fail_expect_with_cfg_attr.stderr +++ b/crates/no_expect_outside_tests/ui/fail_expect_with_cfg_attr.stderr @@ -4,7 +4,7 @@ error: Avoid calling expect on `std::option::Option` outside test-only code LL | let _ = value.expect("handler should not ignore errors"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | - = note: The call originates within function `handler` which is not recognised as a test. + = note: The call originates within function `handler` which is not recognized as a test. = help: Handle the `None` variant of `std::option::Option` or move the code into a test. note: the lint level is defined here --> $DIR/fail_expect_with_cfg_attr.rs:1:9 diff --git a/crates/no_std_fs_operations/Cargo.toml b/crates/no_std_fs_operations/Cargo.toml index 05528b78d..521cd2e74 100644 --- a/crates/no_std_fs_operations/Cargo.toml +++ b/crates/no_std_fs_operations/Cargo.toml @@ -35,6 +35,7 @@ serde = { workspace = true, optional = true } whitaker = { workspace = true, features = ["dylint-driver"], optional = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } anyhow = "1.0" cargo_metadata = { workspace = true } camino = { workspace = true } diff --git a/crates/no_std_fs_operations/src/behaviour.rs b/crates/no_std_fs_operations/src/behaviour.rs index 4e3cb6174..d3b848271 100644 --- a/crates/no_std_fs_operations/src/behaviour.rs +++ b/crates/no_std_fs_operations/src/behaviour.rs @@ -1,6 +1,6 @@ //! Behaviour-driven localization tests for the `no_std_fs_operations` lint. -use crate::diagnostics::{StdFsMessages, localised_messages}; +use crate::diagnostics::{StdFsMessages, localized_messages}; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use std::cell::RefCell; @@ -31,10 +31,10 @@ impl LocalizationWorld { fn resolve(&mut self) { let op = self.operation.clone(); let result = if self.failing { - localised_messages(&FailingLookup::new("no_std_fs_operations"), &op) + localized_messages(&FailingLookup::new("no_std_fs_operations"), &op) } else { let localizer = self.localizer.as_ref().expect("a locale must be selected"); - localised_messages(localizer, &op) + localized_messages(localizer, &op) }; self.result = Some(result); } @@ -83,8 +83,8 @@ fn given_failure(world: &WorldCell) { world.borrow_mut().mark_failure(); } -#[when("I localise the std::fs diagnostic")] -fn when_localise(world: &WorldCell) { +#[when("I localize the std::fs diagnostic")] +fn when_localize(world: &WorldCell) { world.borrow_mut().resolve(); } diff --git a/crates/no_std_fs_operations/src/diagnostics.rs b/crates/no_std_fs_operations/src/diagnostics.rs index 6ba5a22df..77c77cb31 100644 --- a/crates/no_std_fs_operations/src/diagnostics.rs +++ b/crates/no_std_fs_operations/src/diagnostics.rs @@ -75,7 +75,7 @@ fn sanitize_message(text: String) -> String { } #[cfg(test)] -pub(crate) fn localised_messages( +pub(crate) fn localized_messages( lookup: &impl BundleLookup, operation: &str, ) -> Result { diff --git a/crates/no_std_fs_operations/src/exclusion_behaviour.rs b/crates/no_std_fs_operations/src/exclusion_behaviour.rs index e77e9ab8d..db1e41d1f 100644 --- a/crates/no_std_fs_operations/src/exclusion_behaviour.rs +++ b/crates/no_std_fs_operations/src/exclusion_behaviour.rs @@ -34,6 +34,7 @@ impl ExclusionWorld { type WorldCell = RefCell; +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn world() -> WorldCell { RefCell::new(ExclusionWorld::default()) diff --git a/crates/no_std_fs_operations/src/usage.rs b/crates/no_std_fs_operations/src/usage/mod.rs similarity index 100% rename from crates/no_std_fs_operations/src/usage.rs rename to crates/no_std_fs_operations/src/usage/mod.rs diff --git a/crates/no_std_fs_operations/src/usage/tests.rs b/crates/no_std_fs_operations/src/usage/tests.rs index d7c9b7947..25114112b 100644 --- a/crates/no_std_fs_operations/src/usage/tests.rs +++ b/crates/no_std_fs_operations/src/usage/tests.rs @@ -21,7 +21,7 @@ use rstest::rstest; #[case("fs::std", false)] #[case("std::", false)] #[case("std::filesystem", false)] -fn recognises_std_fs_paths(#[case] path: &str, #[case] expected: bool) { +fn recognizes_std_fs_paths(#[case] path: &str, #[case] expected: bool) { assert_eq!(label_is_std_fs(path), expected); } diff --git a/crates/no_std_fs_operations/tests/features/localization.feature b/crates/no_std_fs_operations/tests/features/localization.feature index 41ac1d823..de9376544 100644 --- a/crates/no_std_fs_operations/tests/features/localization.feature +++ b/crates/no_std_fs_operations/tests/features/localization.feature @@ -1,9 +1,9 @@ -Feature: Localised diagnostics for std::fs usage +Feature: Localized diagnostics for std::fs usage Scenario: English messaging encourages cap-std Given the locale "en-GB" is selected And the operation is "std::fs::read_to_string" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then the primary mentions "std::fs::read_to_string" And the note references "cap_std::fs::Dir" And the help references "camino::Utf8Path" @@ -11,26 +11,26 @@ Feature: Localised diagnostics for std::fs usage Scenario: Welsh messaging reflects the capability note Given the locale "cy" is selected And the operation is "std::fs::remove_file" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then the primary mentions "std::fs::remove_file" And the note references "cyfeiriadur" Scenario: Scottish Gaelic messaging mirrors the policy Given the locale "gd" is selected And the operation is "std::fs::metadata" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then the primary mentions "std::fs::metadata" And the note references "neach-gairm" Scenario: Unsupported locale falls back to English Given the locale "zz" is selected And the operation is "std::fs::read_dir" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then the primary mentions "std::fs::read_dir" And the help references "cap_std::fs::Dir" Scenario: Localization failure surfaces the missing key Given localization fails And the operation is "std::fs::canonicalize" - When I localise the std::fs diagnostic + When I localize the std::fs diagnostic Then localization fails for "no_std_fs_operations" diff --git a/crates/no_unwrap_or_else_panic/examples/fail_unwrap_in_rstest_non_test_module.rs b/crates/no_unwrap_or_else_panic/examples/fail_unwrap_in_rstest_non_test_module.rs index 36fd16361..8315dc7d1 100644 --- a/crates/no_unwrap_or_else_panic/examples/fail_unwrap_in_rstest_non_test_module.rs +++ b/crates/no_unwrap_or_else_panic/examples/fail_unwrap_in_rstest_non_test_module.rs @@ -35,7 +35,7 @@ fn parse() { /// Const-only sibling module used as a fixture. /// /// Verifies that `collect_rstest_companion_test_functions` does not mistake an -/// arbitrary `const`-only module for a synthesised rstest harness descriptor. +/// arbitrary `const`-only module for a synthesized rstest harness descriptor. /// A genuine rstest companion module contains `#[test]` functions generated /// by the proc-macro; a module containing only `pub const` items must never /// be treated as one. diff --git a/crates/no_unwrap_or_else_panic/src/context.rs b/crates/no_unwrap_or_else_panic/src/context/mod.rs similarity index 99% rename from crates/no_unwrap_or_else_panic/src/context.rs rename to crates/no_unwrap_or_else_panic/src/context/mod.rs index ffc03bd60..58426ee47 100644 --- a/crates/no_unwrap_or_else_panic/src/context.rs +++ b/crates/no_unwrap_or_else_panic/src/context/mod.rs @@ -31,7 +31,7 @@ use rustc_span::sym; /// Summarize the context for a given HIR node. #[cfg(feature = "dylint-driver")] -pub(crate) fn summarise_context<'tcx>( +pub(crate) fn summarize_context<'tcx>( cx: &LateContext<'tcx>, hir_id: hir::HirId, ) -> ContextSummary { diff --git a/crates/no_unwrap_or_else_panic/src/driver.rs b/crates/no_unwrap_or_else_panic/src/driver.rs index 2260f891a..39b4e1ed0 100644 --- a/crates/no_unwrap_or_else_panic/src/driver.rs +++ b/crates/no_unwrap_or_else_panic/src/driver.rs @@ -98,7 +98,7 @@ impl<'tcx> LateLintPass<'tcx> for NoUnwrapOrElsePanic { return; }; - let summary = summarise_context_with_harness( + let summary = summarize_context_with_harness( cx, expr.hir_id, self.is_test_harness, @@ -134,13 +134,13 @@ fn is_inside_harness_test_function<'tcx>( /// Summarizes the lint context for an expression, merging attribute-based and /// harness-based test detection into a single immutable result. -fn summarise_context_with_harness<'tcx>( +fn summarize_context_with_harness<'tcx>( cx: &LateContext<'tcx>, hir_id: hir::HirId, is_test_harness: bool, harness_test_functions: &HashSet, ) -> ContextSummary { - let mut summary = crate::context::summarise_context(cx, hir_id); + let mut summary = crate::context::summarize_context(cx, hir_id); if !summary.is_test && is_test_harness { summary.is_test = is_inside_harness_test_function(cx, hir_id, harness_test_functions); } diff --git a/crates/test_must_not_have_example/Cargo.toml b/crates/test_must_not_have_example/Cargo.toml index 89905953d..4f0359890 100644 --- a/crates/test_must_not_have_example/Cargo.toml +++ b/crates/test_must_not_have_example/Cargo.toml @@ -34,6 +34,7 @@ log = { workspace = true, optional = true } whitaker = { version = "0.2.9", path = "../../", features = ["dylint-driver"], optional = true } [dev-dependencies] +whitaker_test_macros = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } diff --git a/crates/test_must_not_have_example/src/behaviour.rs b/crates/test_must_not_have_example/src/behaviour.rs index 522268c86..52535f429 100644 --- a/crates/test_must_not_have_example/src/behaviour.rs +++ b/crates/test_must_not_have_example/src/behaviour.rs @@ -26,6 +26,7 @@ impl DocumentationWorld { } } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn world() -> DocumentationWorld { DocumentationWorld::default() diff --git a/crates/whitaker_clones_core/src/index/tests.rs b/crates/whitaker_clones_core/src/index/tests.rs index 2f9871c5d..fd99e7044 100644 --- a/crates/whitaker_clones_core/src/index/tests.rs +++ b/crates/whitaker_clones_core/src/index/tests.rs @@ -1,6 +1,7 @@ //! Unit tests for MinHash and LSH candidate generation. use rstest::{fixture, rstest}; +use whitaker_test_macros::allow_fixture_expansion_lints; use crate::token::Fingerprint; @@ -33,16 +34,19 @@ fn multi_band_config() -> LshConfig { LshConfig::new(32, 4).expect("multi-band config should validate") } +#[allow_fixture_expansion_lints] #[fixture] fn shared_signature() -> MinHashSignature { sketch(&[1, 2, 3, 4]) } +#[allow_fixture_expansion_lints] #[fixture] fn distinct_signature() -> MinHashSignature { sketch(&[8, 9, 10, 11]) } +#[allow_fixture_expansion_lints] #[fixture] fn identical_signature() -> MinHashSignature { sketch(&[5, 7, 11, 13]) diff --git a/crates/whitaker_clones_core/src/run0/emit.rs b/crates/whitaker_clones_core/src/run0/emit.rs index 9c9ece949..b1d747e9f 100644 --- a/crates/whitaker_clones_core/src/run0/emit.rs +++ b/crates/whitaker_clones_core/src/run0/emit.rs @@ -160,7 +160,7 @@ fn build_result( text: format!("Peer clone fragment: {}", peer.id().as_str()), }), physical_location: whitaker_sarif::PhysicalLocation { - artifact_location: whitaker_sarif::ArtifactLocation { + artefact_location: whitaker_sarif::ArtefactLocation { uri: peer.file_uri().to_owned(), uri_base_id: None, }, diff --git a/crates/whitaker_clones_core/src/run0/tests.rs b/crates/whitaker_clones_core/src/run0/tests.rs index f33861cf0..321824933 100644 --- a/crates/whitaker_clones_core/src/run0/tests.rs +++ b/crates/whitaker_clones_core/src/run0/tests.rs @@ -191,13 +191,13 @@ fn emit_run0_uses_primary_and_related_locations() { assert_eq!(result.locations.len(), 1); assert_eq!(result.related_locations.len(), 1); assert_eq!( - result.locations[0].physical_location.artifact_location.uri, + result.locations[0].physical_location.artefact_location.uri, "src/a.rs" ); assert_eq!( result.related_locations[0] .physical_location - .artifact_location + .artefact_location .uri, "src/b.rs" ); diff --git a/crates/whitaker_clones_core/src/run0/types.rs b/crates/whitaker_clones_core/src/run0/types.rs index e11af4c5e..8ee0a8229 100644 --- a/crates/whitaker_clones_core/src/run0/types.rs +++ b/crates/whitaker_clones_core/src/run0/types.rs @@ -71,7 +71,7 @@ impl TokenFragment { self.profile } - /// Returns the source artifact URI used in SARIF output. + /// Returns the source artefact URI used in SARIF output. #[must_use] pub fn file_uri(&self) -> &str { self.file_uri.as_str() diff --git a/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs b/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs index 698f84990..cb0ce7010 100644 --- a/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs +++ b/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs @@ -314,7 +314,7 @@ fn then_primary_file_is(world: &Run0World, file_uri: String) { panic!("a primary location must be present"); }; assert_eq!( - location.physical_location.artifact_location.uri, file_uri, + location.physical_location.artefact_location.uri, file_uri, "primary file URI must match the scenario expectation" ); }); diff --git a/crates/whitaker_sarif/src/builders/location_builder.rs b/crates/whitaker_sarif/src/builders/location_builder.rs index e3fc7febd..ff5b161a5 100644 --- a/crates/whitaker_sarif/src/builders/location_builder.rs +++ b/crates/whitaker_sarif/src/builders/location_builder.rs @@ -1,7 +1,7 @@ //! Builders for [`Location`] and [`Region`] objects. use crate::error::SarifError; -use crate::model::location::{ArtifactLocation, Location, PhysicalLocation, Region}; +use crate::model::location::{ArtefactLocation, Location, PhysicalLocation, Region}; /// Fluent builder for constructing a [`Region`]. /// @@ -64,7 +64,7 @@ impl RegionBuilder { self } - /// Sets the byte offset from the start of the artifact. + /// Sets the byte offset from the start of the artefact. #[must_use] pub fn with_byte_offset(mut self, offset: usize) -> Self { self.byte_offset = Some(offset); @@ -163,7 +163,7 @@ impl RegionBuilder { /// let loc = LocationBuilder::new("src/main.rs") /// .with_region(RegionBuilder::new(10).with_end_line(15).build().expect("valid region")) /// .build(); -/// assert_eq!(loc.physical_location.artifact_location.uri, "src/main.rs"); +/// assert_eq!(loc.physical_location.artefact_location.uri, "src/main.rs"); /// ``` #[derive(Debug, Clone)] pub struct LocationBuilder { @@ -190,7 +190,7 @@ impl LocationBuilder { self } - /// Sets the region within the artifact. + /// Sets the region within the artefact. #[must_use] pub fn with_region(mut self, region: Region) -> Self { self.region = Some(region); @@ -202,7 +202,7 @@ impl LocationBuilder { pub fn build(self) -> Location { Location { physical_location: PhysicalLocation { - artifact_location: ArtifactLocation { + artefact_location: ArtefactLocation { uri: self.uri, uri_base_id: self.uri_base_id, }, @@ -255,7 +255,7 @@ mod tests { #[test] fn location_builder_minimal() { let loc = LocationBuilder::new("src/main.rs").build(); - assert_eq!(loc.physical_location.artifact_location.uri, "src/main.rs"); + assert_eq!(loc.physical_location.artefact_location.uri, "src/main.rs"); assert!(loc.physical_location.region.is_none()); } @@ -281,7 +281,7 @@ mod tests { .build(); assert_eq!( loc.physical_location - .artifact_location + .artefact_location .uri_base_id .as_deref(), Some("%SRCROOT%") diff --git a/crates/whitaker_sarif/src/builders/log_builder.rs b/crates/whitaker_sarif/src/builders/log_builder.rs index 76911f6a5..53a25b8c8 100644 --- a/crates/whitaker_sarif/src/builders/log_builder.rs +++ b/crates/whitaker_sarif/src/builders/log_builder.rs @@ -111,7 +111,7 @@ mod tests { }, invocations: Vec::new(), results: Vec::new(), - artifacts: Vec::new(), + artefacts: Vec::new(), }; let log = SarifLogBuilder::new().with_run(run).build(); assert_eq!(log.runs.len(), 1); diff --git a/crates/whitaker_sarif/src/builders/run_builder.rs b/crates/whitaker_sarif/src/builders/run_builder.rs index e8d1b944c..a436eb3b2 100644 --- a/crates/whitaker_sarif/src/builders/run_builder.rs +++ b/crates/whitaker_sarif/src/builders/run_builder.rs @@ -2,7 +2,7 @@ use crate::model::descriptor::ReportingDescriptor; use crate::model::result::SarifResult; -use crate::model::run::{Artifact, Invocation, Run, Tool, ToolComponent}; +use crate::model::run::{Artefact, Invocation, Run, Tool, ToolComponent}; /// Fluent builder for constructing a [`Run`]. /// @@ -22,7 +22,7 @@ pub struct RunBuilder { rules: Vec, invocations: Vec, results: Vec, - artifacts: Vec, + artefacts: Vec, } impl RunBuilder { @@ -36,7 +36,7 @@ impl RunBuilder { rules: Vec::new(), invocations: Vec::new(), results: Vec::new(), - artifacts: Vec::new(), + artefacts: Vec::new(), } } @@ -117,27 +117,27 @@ impl RunBuilder { self } - /// Appends an artifact reference. + /// Appends an artefact reference. /// /// # Examples /// /// ``` - /// use whitaker_sarif::{RunBuilder, Artifact, ArtifactLocation}; + /// use whitaker_sarif::{RunBuilder, Artefact, ArtefactLocation}; /// /// let run = RunBuilder::new("tool", "1.0") - /// .with_artifact(Artifact { - /// location: ArtifactLocation { + /// .with_artefact(Artefact { + /// location: ArtefactLocation { /// uri: "src/main.rs".into(), /// uri_base_id: None, /// }, /// mime_type: Some("text/x-rust".into()), /// }) /// .build(); - /// assert_eq!(run.artifacts.len(), 1); + /// assert_eq!(run.artefacts.len(), 1); /// ``` #[must_use] - pub fn with_artifact(mut self, artifact: Artifact) -> Self { - self.artifacts.push(artifact); + pub fn with_artefact(mut self, artefact: Artefact) -> Self { + self.artefacts.push(artefact); self } @@ -166,7 +166,7 @@ impl RunBuilder { }, invocations: self.invocations, results: self.results, - artifacts: self.artifacts, + artefacts: self.artefacts, } } } diff --git a/crates/whitaker_sarif/src/lib.rs b/crates/whitaker_sarif/src/lib.rs index bf4cde4a9..63514b6ba 100644 --- a/crates/whitaker_sarif/src/lib.rs +++ b/crates/whitaker_sarif/src/lib.rs @@ -27,7 +27,7 @@ pub use error::{Result, SarifError}; // Model types pub use model::{ - Artifact, ArtifactLocation, Invocation, Level, Location, Message, MultiformatMessageString, + Artefact, ArtefactLocation, Invocation, Level, Location, Message, MultiformatMessageString, PhysicalLocation, Region, RelatedLocation, ReportingDescriptor, Run, SarifLog, SarifResult, Tool, ToolComponent, }; diff --git a/crates/whitaker_sarif/src/merge.rs b/crates/whitaker_sarif/src/merge.rs index 3e76a0116..e16bf4331 100644 --- a/crates/whitaker_sarif/src/merge.rs +++ b/crates/whitaker_sarif/src/merge.rs @@ -66,7 +66,7 @@ fn extract_key(result: &SarifResult) -> Option { .clone(); let location = result.locations.first()?; - let file = location.physical_location.artifact_location.uri.clone(); + let file = location.physical_location.artefact_location.uri.clone(); let region = location.physical_location.region.as_ref()?; Some(ResultKey { @@ -127,7 +127,7 @@ pub fn deduplicate_results(results: &[SarifResult]) -> Vec { /// /// The tool metadata is taken from the first run. Rules are unioned across all /// runs by `id` (first occurrence wins). Results are collected from all runs -/// and deduplicated. Artifacts and invocations are concatenated. +/// and deduplicated. Artefacts and invocations are concatenated. /// /// # Errors /// @@ -152,12 +152,12 @@ pub fn merge_runs(runs: &[Run]) -> Result { tool.driver.rules = union_rules(runs); let mut all_results = Vec::new(); - let mut all_artifacts = Vec::new(); + let mut all_artefacts = Vec::new(); let mut all_invocations = Vec::new(); for run in runs { all_results.extend(run.results.clone()); - all_artifacts.extend(run.artifacts.clone()); + all_artefacts.extend(run.artefacts.clone()); all_invocations.extend(run.invocations.clone()); } @@ -167,7 +167,7 @@ pub fn merge_runs(runs: &[Run]) -> Result { tool, invocations: all_invocations, results, - artifacts: all_artifacts, + artefacts: all_artefacts, }) } diff --git a/crates/whitaker_sarif/src/model/location.rs b/crates/whitaker_sarif/src/model/location.rs index 06d8389d8..ccb0e81ff 100644 --- a/crates/whitaker_sarif/src/model/location.rs +++ b/crates/whitaker_sarif/src/model/location.rs @@ -2,21 +2,21 @@ //! //! These types describe where a result was found in source code. A //! [`Location`] wraps a [`PhysicalLocation`] which combines an -//! [`ArtifactLocation`] (file URI) with an optional [`Region`] (line and +//! [`ArtefactLocation`] (file URI) with an optional [`Region`] (line and //! column spans). use serde::{Deserialize, Serialize}; -/// A location within an artifact (source file). +/// A location within an artefact (source file). /// /// # Examples /// /// ``` -/// use whitaker_sarif::{Location, PhysicalLocation, ArtifactLocation, Region}; +/// use whitaker_sarif::{Location, PhysicalLocation, ArtefactLocation, Region}; /// /// let loc = Location { /// physical_location: PhysicalLocation { -/// artifact_location: ArtifactLocation { +/// artefact_location: ArtefactLocation { /// uri: "src/main.rs".into(), /// uri_base_id: None, /// }, @@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize}; /// }), /// }, /// }; -/// assert_eq!(loc.physical_location.artifact_location.uri, "src/main.rs"); +/// assert_eq!(loc.physical_location.artefact_location.uri, "src/main.rs"); /// ``` #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -43,19 +43,23 @@ pub struct Location { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PhysicalLocation { - /// Identifies the artifact (file). - pub artifact_location: ArtifactLocation, - - /// Optional region within the artifact. + /// Identifies the artefact (file). + /// + /// The SARIF 2.1.0 schema spells this property `artifactLocation`, so the + /// wire name is pinned here rather than derived from the field name. + #[serde(rename = "artifactLocation")] + pub artefact_location: ArtefactLocation, + + /// Optional region within the artefact. #[serde(default, skip_serializing_if = "Option::is_none")] pub region: Option, } -/// A reference to an artifact by URI. +/// A reference to an artefact by URI. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct ArtifactLocation { - /// Relative or absolute URI of the artifact. +pub struct ArtefactLocation { + /// Relative or absolute URI of the artefact. pub uri: String, /// Base identifier for resolving relative URIs. @@ -63,7 +67,7 @@ pub struct ArtifactLocation { pub uri_base_id: Option, } -/// A region within an artifact, identified by line and column numbers. +/// A region within an artefact, identified by line and column numbers. /// /// `start_line` is always required (1-based). All other fields are optional. /// @@ -100,7 +104,7 @@ pub struct Region { #[serde(default, skip_serializing_if = "Option::is_none")] pub end_column: Option, - /// Optional byte offset from the start of the artifact. + /// Optional byte offset from the start of the artefact. #[serde(default, skip_serializing_if = "Option::is_none")] pub byte_offset: Option, @@ -170,7 +174,7 @@ mod tests { fn location_round_trip() { let loc = Location { physical_location: PhysicalLocation { - artifact_location: ArtifactLocation { + artefact_location: ArtefactLocation { uri: "src/lib.rs".into(), uri_base_id: Some("%SRCROOT%".into()), }, @@ -195,7 +199,7 @@ mod tests { text: "peer fragment".into(), }), physical_location: PhysicalLocation { - artifact_location: ArtifactLocation { + artefact_location: ArtefactLocation { uri: "src/other.rs".into(), uri_base_id: None, }, diff --git a/crates/whitaker_sarif/src/model/mod.rs b/crates/whitaker_sarif/src/model/mod.rs index a38b1493c..7869f6e4d 100644 --- a/crates/whitaker_sarif/src/model/mod.rs +++ b/crates/whitaker_sarif/src/model/mod.rs @@ -5,10 +5,10 @@ //! //! - [`log`] โ€” top-level [`SarifLog`] container. //! - [`run`] โ€” [`Run`], [`Tool`], [`ToolComponent`], [`Invocation`], and -//! [`Artifact`]. +//! [`Artefact`]. //! - [`result`] โ€” [`SarifResult`], [`Level`], and [`Message`]. //! - [`location`] โ€” [`Location`], [`PhysicalLocation`], -//! [`ArtifactLocation`], [`Region`], and [`RelatedLocation`]. +//! [`ArtefactLocation`], [`Region`], and [`RelatedLocation`]. //! - [`descriptor`] โ€” [`ReportingDescriptor`] and //! [`MultiformatMessageString`]. //! @@ -22,7 +22,7 @@ pub mod result; pub mod run; pub use descriptor::{MultiformatMessageString, ReportingDescriptor}; -pub use location::{ArtifactLocation, Location, PhysicalLocation, Region, RelatedLocation}; +pub use location::{ArtefactLocation, Location, PhysicalLocation, Region, RelatedLocation}; pub use log::SarifLog; pub use result::{Level, Message, SarifResult}; -pub use run::{Artifact, Invocation, Run, Tool, ToolComponent}; +pub use run::{Artefact, Invocation, Run, Tool, ToolComponent}; diff --git a/crates/whitaker_sarif/src/model/run.rs b/crates/whitaker_sarif/src/model/run.rs index 7368b2332..532b07ddd 100644 --- a/crates/whitaker_sarif/src/model/run.rs +++ b/crates/whitaker_sarif/src/model/run.rs @@ -1,9 +1,9 @@ -//! SARIF run, tool, invocation, and artifact types. +//! SARIF run, tool, invocation, and artefact types. //! //! A [`Run`] represents a single execution of an analysis tool. It contains //! the [`Tool`] that produced the results, optional [`Invocation`] metadata, //! the [`SarifResult`] findings, and any referenced -//! [`Artifact`]s. +//! [`Artefact`]s. use serde::{Deserialize, Serialize}; @@ -28,7 +28,7 @@ use super::result::SarifResult; /// }, /// invocations: Vec::new(), /// results: Vec::new(), -/// artifacts: Vec::new(), +/// artefacts: Vec::new(), /// }; /// assert_eq!(run.tool.driver.name, "whitaker_clones_cli"); /// ``` @@ -46,9 +46,12 @@ pub struct Run { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub results: Vec, - /// Referenced source artifacts. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub artifacts: Vec, + /// Referenced source artefacts. + /// + /// The SARIF 2.1.0 schema spells this property `artifacts`, so the wire + /// name is pinned here rather than derived from the field name. + #[serde(default, rename = "artifacts", skip_serializing_if = "Vec::is_empty")] + pub artefacts: Vec, } /// The analysis tool that produced a run. @@ -117,27 +120,27 @@ pub struct Invocation { pub command_line: Option, } -/// A source artifact referenced by results. +/// A source artefact referenced by results. /// /// # Examples /// /// ``` -/// use whitaker_sarif::{Artifact, ArtifactLocation}; +/// use whitaker_sarif::{Artefact, ArtefactLocation}; /// -/// let artifact = Artifact { -/// location: ArtifactLocation { +/// let artefact = Artefact { +/// location: ArtefactLocation { /// uri: "src/main.rs".into(), /// uri_base_id: None, /// }, /// mime_type: Some("text/x-rust".into()), /// }; -/// assert_eq!(artifact.location.uri, "src/main.rs"); +/// assert_eq!(artefact.location.uri, "src/main.rs"); /// ``` #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub struct Artifact { - /// Location of the artifact. - pub location: super::location::ArtifactLocation, +pub struct Artefact { + /// Location of the artefact. + pub location: super::location::ArtefactLocation, /// Optional MIME type. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -150,7 +153,7 @@ mod tests { //! shared JSON assertion helpers. use super::*; - use crate::model::location::ArtifactLocation; + use crate::model::location::ArtefactLocation; use crate::test_support::{assert_json_round_trip, assert_serialized_json}; #[test] @@ -169,7 +172,7 @@ mod tests { command_line: None, }], results: Vec::new(), - artifacts: Vec::new(), + artefacts: Vec::new(), }; assert_json_round_trip(&run); } @@ -187,7 +190,7 @@ mod tests { }, invocations: Vec::new(), results: Vec::new(), - artifacts: Vec::new(), + artefacts: Vec::new(), }; assert_serialized_json(&run, |json| { assert!( @@ -207,9 +210,9 @@ mod tests { } #[test] - fn artifact_round_trip() { - let artefact = Artifact { - location: ArtifactLocation { + fn artefact_round_trip() { + let artefact = Artefact { + location: ArtefactLocation { uri: "src/lib.rs".into(), uri_base_id: Some("%SRCROOT%".into()), }, diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 30231f93b..c4f9b7082 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -2922,7 +2922,7 @@ context by combining a HIR ancestry walk with attribute-shape matching. `ContextEntry` items for modules, functions, impls, and blocks, and carries a boolean `has_test_context_ancestry` alongside that list. On each step, `has_test_ancestry` updates that boolean so the test-only decision can -propagate from outer ancestors into nested helper code. `summarise_context` +propagate from outer ancestors into nested helper code. `summarize_context` then combines the accumulated entries, the propagated boolean, and `in_test_like_context_with(additional_test_attributes)` to produce the final `ContextSummary.is_test` result. This pattern matters because user-configured @@ -2936,7 +2936,7 @@ not just the immediately enclosing function. `is_cfg_test_attribute` - the current ancestor is a function item whose attributes match Whitaker's built-in test list or `additional_test_attributes` -- `summarise_context` merges that ancestry flag with the collected +- `summarize_context` merges that ancestry flag with the collected `ContextEntry` values to derive the final `ContextSummary.is_test` decision. rustc removes an evaluated `#[cfg(test)]` from the item and leaves a parsed diff --git a/docs/execplans/2-2-5-test-must-not-have-example-lint.md b/docs/execplans/2-2-5-test-must-not-have-example-lint.md index a85ff270e..a6c6e7e20 100644 --- a/docs/execplans/2-2-5-test-must-not-have-example-lint.md +++ b/docs/execplans/2-2-5-test-must-not-have-example-lint.md @@ -211,7 +211,7 @@ Current repository state relevant to this task: - Existing reusable detection helpers already exist: - `common::context::{is_test_fn_with, in_test_like_context_with}`; - `crates/no_expect_outside_tests/src/context.rs` for HIR ancestor collection - and `cfg(test)` handling (`collect_context`, `summarise_context`). + and `cfg(test)` handling (`collect_context`, `summarize_context`). - Existing BDD tests use `rstest_bdd_macros::{given, when, then, scenario}` and are executed under `cargo test`. diff --git a/docs/execplans/3-4-4-install-prebuilt-artefacts.md b/docs/execplans/3-4-4-install-prebuilt-artefacts.md index f3252a249..568a849fe 100644 --- a/docs/execplans/3-4-4-install-prebuilt-artefacts.md +++ b/docs/execplans/3-4-4-install-prebuilt-artefacts.md @@ -558,7 +558,7 @@ All phases produce additive changes. If a phase fails partway through, fix the issue and re-run the quality gates. The `tempfile` crate ensures downloaded archives are cleaned up on failure. -## Artifacts and notes +## Artefacts and notes Key files created: diff --git a/docs/execplans/3.4.6. Record download-versus-build rates.md b/docs/execplans/3.4.6. Record download-versus-build rates.md index 05af2d05d..2c300c785 100644 --- a/docs/execplans/3.4.6. Record download-versus-build rates.md +++ b/docs/execplans/3.4.6. Record download-versus-build rates.md @@ -356,7 +356,7 @@ Expected observable checks after implementation: - If metrics write fails, continue installation and log a warning; retry on next installation. -## Artifacts and notes +## Artefacts and notes During implementation, capture concise evidence snippets in this section: diff --git a/docs/execplans/7-1-1-whitaker-sarif-crate.md b/docs/execplans/7-1-1-whitaker-sarif-crate.md index ac6ed9665..44cd6e7c9 100644 --- a/docs/execplans/7-1-1-whitaker-sarif-crate.md +++ b/docs/execplans/7-1-1-whitaker-sarif-crate.md @@ -263,9 +263,9 @@ field. Convenience type alias `Result`. ### Stage D: Implement SARIF model types Split into `model/` directory: `mod.rs`, `log.rs` (`SarifLog`), `run.rs` (`Run`, -`Tool`, `ToolComponent`, `Invocation`, `Artifact`), `result.rs` (`SarifResult`, +`Tool`, `ToolComponent`, `Invocation`, `Artefact`), `result.rs` (`SarifResult`, `Level`, `Message`), `location.rs` (`Location`, `PhysicalLocation`, -`ArtifactLocation`, `Region`, `RelatedLocation`), `descriptor.rs` +`ArtefactLocation`, `Region`, `RelatedLocation`), `descriptor.rs` (`ReportingDescriptor`, `MultiformatMessageString`). All types derive `Debug`, `Clone`, `PartialEq`, `Serialize`, `Deserialize` with `#[serde(rename_all = "camelCase")]`. diff --git a/docs/execplans/7-2-8-kani-verification-of-bounded-lsh-index-invariants.md b/docs/execplans/7-2-8-kani-verification-of-bounded-lsh-index-invariants.md index d72b85813..8477738ea 100644 --- a/docs/execplans/7-2-8-kani-verification-of-bounded-lsh-index-invariants.md +++ b/docs/execplans/7-2-8-kani-verification-of-bounded-lsh-index-invariants.md @@ -679,7 +679,7 @@ If a quality gate fails because of unrelated main-branch drift or another agent's changes, stop, record the evidence in this plan, and ask for direction. Do not revert unrelated work. -## Artifacts and notes +## Artefacts and notes Wyvern planning agents reported three useful facts: diff --git a/docs/execplans/7-3-1-map-candidate-spans-and-extract-ast-feature-vectors.md b/docs/execplans/7-3-1-map-candidate-spans-and-extract-ast-feature-vectors.md index ae2e52dde..bf8e223f8 100644 --- a/docs/execplans/7-3-1-map-candidate-spans-and-extract-ast-feature-vectors.md +++ b/docs/execplans/7-3-1-map-candidate-spans-and-extract-ast-feature-vectors.md @@ -77,7 +77,7 @@ escalation, not a workaround. bare `ra_ap_syntax::`/`rowan::` path appears outside comments, with the forbidden-crate list as a `const`. - **No persisted `KindId` from 7.3.1.** Only `AstHash` (which is seeded with - `PARSER_SCHEMA_VERSION`) is hashable/serialisable in this item. `KindId` is + `PARSER_SCHEMA_VERSION`) is hashable/serializable in this item. `KindId` is an in-memory opaque token and must not be persisted, so a future cache (7.6.x) cannot accidentally compare raw discriminants across parser pins. - **Bounded per-candidate cost.** Lowering touches one candidate subtree; the @@ -617,7 +617,7 @@ Decisions already taken while drafting this plan: corruption, no crash). Seeding makes every hash change on a bump, so any cross-pin cache compare fails closed. An `insta` snapshot of `PARSER_SCHEMA_VERSION` forces any bump to be reviewed. `KindId` itself is - **not** persisted by 7.3.1 (only `AstHash` is hashable/serialisable here); + **not** persisted by 7.3.1 (only `AstHash` is hashable/serializable here); this is stated as a Constraint so 7.6.x inherits the rule. Date/Author: 2026-06-09, Doggylump (review panel). - Decision: **Promote the FNV-1a constants and byte-mixing step from diff --git a/docs/execplans/8-2-1-create-the-rstest-helper-lint-crate.md b/docs/execplans/8-2-1-create-the-rstest-helper-lint-crate.md index b168c6d52..770a2066a 100644 --- a/docs/execplans/8-2-1-create-the-rstest-helper-lint-crate.md +++ b/docs/execplans/8-2-1-create-the-rstest-helper-lint-crate.md @@ -730,7 +730,7 @@ If validation fails, inspect the matching changing code. Record persistent failures in `Surprises & Discoveries` or `Decision Log` with the command and log path. -## Artifacts and notes +## Artefacts and notes Wyvern repository-pattern findings: diff --git a/docs/execplans/8-2-2-call-site-collection-in-rstest-tests.md b/docs/execplans/8-2-2-call-site-collection-in-rstest-tests.md index 84b0648d9..a0ae48ce2 100644 --- a/docs/execplans/8-2-2-call-site-collection-in-rstest-tests.md +++ b/docs/execplans/8-2-2-call-site-collection-in-rstest-tests.md @@ -892,13 +892,13 @@ If validation fails, inspect the matching changing code. Record persistent failures under "Surprises & discoveries" or "Decision log" with the command and log path. -## Artifacts and notes +## Artefacts and notes Wyvern repository-pattern findings (summary): ```plaintext - No existing Whitaker lint uses check_crate_post. The current lints - initialise per-crate config in check_crate and emit diagnostics + initialize per-crate config in check_crate and emit diagnostics expression-locally through check_expr / check_fn / check_item / check_impl_item. - Callee resolution is consistently done via cx.qpath_res for Call and diff --git a/docs/execplans/installer-does-not-install-toolchain.md b/docs/execplans/installer-does-not-install-toolchain.md index fc33b4e21..2c0ed3775 100644 --- a/docs/execplans/installer-does-not-install-toolchain.md +++ b/docs/execplans/installer-does-not-install-toolchain.md @@ -186,7 +186,7 @@ already installed, no rustup install command should run. If rustup fails, the installer should return an error and can be re-run once the environment recovers. -## Artifacts and Notes +## Artefacts and Notes Example log (expected after change): diff --git a/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md b/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md index a0df36c8d..0e6eef526 100644 --- a/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md +++ b/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md @@ -216,7 +216,7 @@ Edits are safe to re-run. If a step fails, revert file changes with Git and re-apply. Validation commands are safe to repeat. If validation fails twice, stop and escalate with the captured logs. -## Artifacts and Notes +## Artefacts and Notes Keep the validation logs from `/tmp/whitaker-*.log` as evidence for each quality gate. Record any output from `cargo dylint list` that shows the updated diff --git a/docs/execplans/issue-94-dual-ownership-of-binary.md b/docs/execplans/issue-94-dual-ownership-of-binary.md index 158b01e30..0c1b8b19b 100644 --- a/docs/execplans/issue-94-dual-ownership-of-binary.md +++ b/docs/execplans/issue-94-dual-ownership-of-binary.md @@ -186,7 +186,7 @@ Edits are safe to re-run. If a step fails, revert the file changes with Git and re-apply. The `install-smoke` target uses a temporary directory and is safe to repeat. If validation fails twice, stop and escalate with the captured logs. -## Artifacts and Notes +## Artefacts and Notes Capture key outputs in the log files listed in Concrete Steps. These logs are sufficient evidence for validation and troubleshooting. diff --git a/docs/local-validation-of-github-actions-with-act-and-pytest.md b/docs/local-validation-of-github-actions-with-act-and-pytest.md index 0b6494adf..be7114f65 100644 --- a/docs/local-validation-of-github-actions-with-act-and-pytest.md +++ b/docs/local-validation-of-github-actions-with-act-and-pytest.md @@ -121,9 +121,9 @@ def run_act( job: str = "selftest", event_path: Path = EVENT, *, - artifact_dir: Path, + artefact_dir: Path, ) -> tuple[int, Path, str]: - artifact_dir.mkdir(parents=True, exist_ok=True) + artefact_dir.mkdir(parents=True, exist_ok=True) cmd = [ "act", "pull_request", @@ -134,18 +134,18 @@ def run_act( "-P", "ubuntu-latest=catthehacker/ubuntu:act-latest", "--artifact-server-path", - str(artifact_dir), + str(artefact_dir), "--json", # machine-parseable log stream "-b", # bind-mount repo as workspace (preserves side effects) ] completed = subprocess.run(cmd, text=True, capture_output=True) logs = completed.stdout + "\n" + completed.stderr - return completed.returncode, artifact_dir, logs + return completed.returncode, artefact_dir, logs def test_workflow_produces_expected_artefact_and_logs(tmp_path: Path) -> None: - artifact_dir = tmp_path / "act-artifacts" - code, artdir, logs = run_act(artifact_dir=artifact_dir) + artefact_dir = tmp_path / "act-artifacts" + code, artdir, logs = run_act(artefact_dir=artefact_dir) assert code == 0, f"act failed:\n{logs}" # Assert artefact presence and contents @@ -214,11 +214,11 @@ loop: from cmd_mox import CmdMox def test_record(tmp_path: Path) -> None: - artifact_dir = tmp_path / "act-artifacts" + artefact_dir = tmp_path / "act-artifacts" with CmdMox() as mox: gh = mox.spy("gh").passthrough() mox.replay() - code, _, logs = run_act(artifact_dir=artifact_dir) + code, _, logs = run_act(artefact_dir=artefact_dir) assert code == 0, logs mox.verify() assert gh.call_count == 1 @@ -229,7 +229,7 @@ loop: ```python def test_replay(tmp_path: Path, cmd_mox) -> None: - artifact_dir = tmp_path / "act-artifacts" + artefact_dir = tmp_path / "act-artifacts" cmd_mox.mock("gh").with_args( "release", "view", @@ -237,7 +237,7 @@ loop: "tagName", ).returns(stdout='{"tagName":"v9.9.9"}\n') cmd_mox.replay() - code, _, logs = run_act(artifact_dir=artifact_dir) + code, _, logs = run_act(artefact_dir=artefact_dir) assert code == 0, logs cmd_mox.verify() ``` diff --git a/docs/rstest-bdd-users-guide.md b/docs/rstest-bdd-users-guide.md index 67db84097..9e2ad2c76 100644 --- a/docs/rstest-bdd-users-guide.md +++ b/docs/rstest-bdd-users-guide.md @@ -988,7 +988,7 @@ assert_eq!(row_number, 2); assert_eq!(column_index, 2); assert!(err .to_string() - .contains("unrecognised boolean value 'maybe'")); + .contains("unrecognized boolean value 'maybe'")); ``` [`DataTableError`]: crate::datatable::DataTableError diff --git a/docs/scripting-standards.md b/docs/scripting-standards.md index 288fc5ff8..15b042b04 100644 --- a/docs/scripting-standards.md +++ b/docs/scripting-standards.md @@ -241,7 +241,7 @@ from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parents[1] DIST = PROJECT_ROOT / "dist" -(DIST / "artifacts").mkdir(parents=True, exist_ok=True) +(DIST / "artefacts").mkdir(parents=True, exist_ok=True) # Portable joins and normalization cfg = PROJECT_ROOT.joinpath("config", "release.toml").resolve() diff --git a/docs/whitaker-clone-detector-design.md b/docs/whitaker-clone-detector-design.md index b627af767..f7a506c84 100644 --- a/docs/whitaker-clone-detector-design.md +++ b/docs/whitaker-clone-detector-design.md @@ -61,7 +61,7 @@ passes. Use `runs[0]` for the token pass and `runs[1]` for the AST pass. Each - `serde` models of SARIF 2.1.0 (subset plus extensions) with `From` and `Into` helpers. -- Helpers to build rules, results, locations, artifacts, and invocations. +- Helpers to build rules, results, locations, artefacts, and invocations. - Stable file layout: `target/whitaker/clones.{pass}.sarif` and `target/whitaker/clones.refined.sarif`. - Merge logic: combine runs and deduplicate results by diff --git a/docs/whitaker-dylint-suite-design.md b/docs/whitaker-dylint-suite-design.md index 451212c5c..b5ba1b955 100644 --- a/docs/whitaker-dylint-suite-design.md +++ b/docs/whitaker-dylint-suite-design.md @@ -1237,7 +1237,7 @@ and predicate complexity. Collect segments `(start_line, end_line, value)` using `SourceMap` mapping and accumulate contributions with weights (`wD = 1.0`, `wP = 0.5`, `wK = 0.5`). -Rasterise once per function to produce a per-line signal `C[line]` representing +Rasterize once per function to produce a per-line signal `C[line]` representing local complexity. ```rust @@ -1295,7 +1295,7 @@ highlight the top two intervals in the diagnostic. 1. Walk the function HIR, updating depth and collecting segments for blocks, branches, and predicate spans. -2. Rasterise segments to per-line values, then smooth with the configured +2. Rasterize segments to per-line values, then smooth with the configured window. 3. Detect bumps where the smoothed value meets or exceeds `threshold`. 4. Emit a diagnostic on the function name span when bumps โ‰ฅ 2, attaching labels @@ -1321,7 +1321,7 @@ functions typically remain below the threshold after smoothing. Deep single nests fall under other lints such as `excessive_nesting`. **Performance.** The pass is linear in the size of each functionโ€™s HIR. Segment -rasterisation touches at most the number of lines in the function, keeping the +rasterization touches at most the number of lines in the function, keeping the overhead negligible for typical Rust code. **Test plan.** Provide UI cases covering two separated nested blocks, @@ -1366,7 +1366,7 @@ function and is suitable as a stable Dylint rule in the default suite. ### Phase 0 โ€” Repo scaffolding -- Initialise workspace +- Initialize workspace - Create `Cargo.toml` with `[workspace]`, resolver = 2, members = `crates/*`, `common`, `suite`, `installer`. - Add `rust-toolchain.toml` (pin nightly) and `rustfmt.toml`. diff --git a/installer/src/artefact/extraction.rs b/installer/src/artefact/extraction.rs index fdb6f812c..0c7e3af3a 100644 --- a/installer/src/artefact/extraction.rs +++ b/installer/src/artefact/extraction.rs @@ -137,8 +137,8 @@ mod tests { builder .append_path_with_name(&source_file, "hello.txt") .expect("append"); - let encoder = builder.into_inner().expect("tar finish"); - encoder.finish().expect("zstd finish"); + let finished_encoder = builder.into_inner().expect("tar finish"); + finished_encoder.finish().expect("zstd finish"); let extractor = ZstdExtractor; let files = extractor @@ -184,8 +184,8 @@ mod tests { let output_file = std::fs::File::create(&archive_path).expect("create"); let encoder = zstd::Encoder::new(output_file, 0).expect("zstd"); let builder = tar::Builder::new(encoder); - let encoder = builder.into_inner().expect("tar finish"); - encoder.finish().expect("zstd finish"); + let finished_encoder = builder.into_inner().expect("tar finish"); + finished_encoder.finish().expect("zstd finish"); let extractor = ZstdExtractor; let result = extractor.extract(&archive_path, &dest_dir); diff --git a/installer/src/artefact/schema_version.rs b/installer/src/artefact/schema_version.rs index 2f0b101a8..2631d8501 100644 --- a/installer/src/artefact/schema_version.rs +++ b/installer/src/artefact/schema_version.rs @@ -100,7 +100,7 @@ mod tests { fn rejects_version_zero() { let result = SchemaVersion::try_from(0_u32); assert!(result.is_err()); - let err = result.unwrap_err(); + let err = result.expect_err("schema version zero must be rejected"); assert!(matches!( err, ArtefactError::UnsupportedSchemaVersion { value: 0, max: 1 } diff --git a/installer/src/artefact/sha256_digest.rs b/installer/src/artefact/sha256_digest.rs index c64cfc143..1332219f4 100644 --- a/installer/src/artefact/sha256_digest.rs +++ b/installer/src/artefact/sha256_digest.rs @@ -129,6 +129,7 @@ mod tests { use super::*; use rstest::{fixture, rstest}; + #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn valid_digest() -> String { "a".repeat(64) diff --git a/installer/src/dependency_binaries/install/checksum.rs b/installer/src/dependency_binaries/install/checksum.rs index 066adb126..ed7ab3347 100644 --- a/installer/src/dependency_binaries/install/checksum.rs +++ b/installer/src/dependency_binaries/install/checksum.rs @@ -260,8 +260,11 @@ mod tests { return Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted")); } let take = self.data.len().min(self.chunk).min(buf.len()); - buf[..take].copy_from_slice(&self.data[..take]); - self.data = &self.data[take..]; + let (to_copy, rest) = self.data.split_at(take); + buf.get_mut(..take) + .expect("take is bounded by buf.len()") + .copy_from_slice(to_copy); + self.data = rest; Ok(take) } } diff --git a/installer/src/dependency_binaries/install/downloader.rs b/installer/src/dependency_binaries/install/downloader.rs index 2b5d4d709..13923ac85 100644 --- a/installer/src/dependency_binaries/install/downloader.rs +++ b/installer/src/dependency_binaries/install/downloader.rs @@ -333,9 +333,10 @@ mod tests { impl Drop for ProbeCleanup { fn drop(&mut self) { - // A correct run never writes the probe into this directory, so a - // missing file is the expected case. - let _ = self.dir.remove_file(&self.name); + if self.dir.remove_file(&self.name).is_err() { + // A correct run never writes the probe into this directory, so + // a missing file is the expected case. + } } } diff --git a/installer/src/dependency_binaries/install/http_test_server.rs b/installer/src/dependency_binaries/install/http_test_server.rs index 17bd35bd1..46f486c23 100644 --- a/installer/src/dependency_binaries/install/http_test_server.rs +++ b/installer/src/dependency_binaries/install/http_test_server.rs @@ -61,9 +61,11 @@ impl LocalServer { let requested = Arc::new(Mutex::new(Vec::new())); let stop = Arc::new(AtomicBool::new(false)); let handle = { - let requested = Arc::clone(&requested); - let stop = Arc::clone(&stop); - thread::spawn(move || run_server(&listener, &routes, &requested, &stop)) + let requested_for_thread = Arc::clone(&requested); + let stop_for_thread = Arc::clone(&stop); + thread::spawn(move || { + run_server(&listener, &routes, &requested_for_thread, &stop_for_thread); + }) }; Self { base_url: format!("http://127.0.0.1:{port}"), @@ -85,8 +87,11 @@ impl LocalServer { impl Drop for LocalServer { fn drop(&mut self) { self.stop.store(true, Ordering::Relaxed); - if let Some(handle) = self.handle.take() { - let _ = handle.join(); + if let Some(handle) = self.handle.take() + && handle.join().is_err() + { + // The server thread panicked; tests observe failures through the + // requests they make, so nothing further can be reported here. } } } @@ -120,9 +125,17 @@ fn serve_connection( // Restore blocking mode and bound reads/writes on the accepted connection // (the listener is non-blocking only so the accept loop can poll for // shutdown). `try_clone` shares the socket, so `peer` inherits these. - let _ = stream.set_nonblocking(false); - let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); - let _ = stream.set_write_timeout(Some(Duration::from_secs(5))); + // Drop the connection if the socket cannot be configured. + if stream.set_nonblocking(false).is_err() + || stream + .set_read_timeout(Some(Duration::from_secs(5))) + .is_err() + || stream + .set_write_timeout(Some(Duration::from_secs(5))) + .is_err() + { + return; + } let Ok(peer) = stream.try_clone() else { return; }; @@ -139,19 +152,24 @@ fn serve_connection( // Drain the remaining request headers up to the blank line. loop { let mut line = String::new(); - match reader.read_line(&mut line) { - Ok(0) => break, - Ok(_) if line == "\r\n" || line == "\n" => break, - Ok(_) => {} - Err(_) => break, + let bytes_read = reader.read_line(&mut line).unwrap_or(0); + if bytes_read == 0 || line == "\r\n" || line == "\n" { + break; } } // Resolve the route first โ€” the returned references borrow `routes`, not // `path` โ€” so the owned `path` can then move into the request log. - let (status_line, body, declared_len): (&str, &[u8], usize) = match routes.get(&path) { - Some(response) => (response.status_line, &response.body, response.declared_len), - None => ("404 Not Found", b"not found", b"not found".len()), - }; + let not_found: &[u8] = b"not found"; + let (status_line, body, declared_len) = routes.get(&path).map_or_else( + || ("404 Not Found", not_found, not_found.len()), + |response| { + ( + response.status_line, + response.body.as_slice(), + response.declared_len, + ) + }, + ); requested.lock().expect("lock requested paths").push(path); let header = format!( concat!( @@ -162,7 +180,11 @@ fn serve_connection( ), status_line, declared_len, ); - let _ = stream.write_all(header.as_bytes()); - let _ = stream.write_all(body); - let _ = stream.flush(); + let write_result = stream + .write_all(header.as_bytes()) + .and_then(|()| stream.write_all(body)) + .and_then(|()| stream.flush()); + if write_result.is_err() { + // The client disconnected early; there is nothing further to serve. + } } diff --git a/installer/src/deps.rs b/installer/src/deps/mod.rs similarity index 100% rename from installer/src/deps.rs rename to installer/src/deps/mod.rs diff --git a/installer/src/output.rs b/installer/src/output.rs index fd62d8cb6..d738919ca 100644 --- a/installer/src/output.rs +++ b/installer/src/output.rs @@ -187,6 +187,7 @@ mod tests { } /// Shared fixture providing a shell snippet for the test path. + #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn test_snippet(test_path: Utf8PathBuf) -> ShellSnippet { ShellSnippet::new(&test_path) @@ -223,7 +224,7 @@ mod tests { #[rstest] #[case::singular(1, "1 lint library")] #[case::plural(5, "5 lint libraries")] - fn success_message_pluralises_correctly(#[case] count: usize, #[case] expected: &str) { + fn success_message_pluralizes_correctly(#[case] count: usize, #[case] expected: &str) { let path = Utf8PathBuf::from("/tmp"); let msg = success_message(count, &path); assert!(msg.contains(expected)); diff --git a/src/config.rs b/src/config.rs index fe7955f76..d223dfdcd 100644 --- a/src/config.rs +++ b/src/config.rs @@ -8,7 +8,7 @@ //! `dylint.toml` when present and fall back to sensible defaults otherwise. use serde::Deserialize; -use whitaker_common::i18n::normalise_locale; +use whitaker_common::i18n::normalize_locale; /// Shared configuration for the workspace-level crate. #[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)] @@ -92,7 +92,7 @@ impl SharedConfig { /// behaviour when `dylint.toml` is templated or patched. #[must_use] pub fn locale(&self) -> Option<&str> { - normalise_locale(self.locale.as_deref()) + normalize_locale(self.locale.as_deref()) } } @@ -133,7 +133,7 @@ mod tests { } #[rstest] - fn deserialises_overrides_from_toml() { + fn deserializes_overrides_from_toml() { let source = "[module_max_lines]\nmax_lines = 120\n"; // Panic with the TOML parser's message so broken overrides are easy to debug. @@ -144,7 +144,7 @@ mod tests { } #[rstest] - fn deserialises_locale_override() { + fn deserializes_locale_override() { let source = "locale = \"cy\"\n"; let config = toml::from_str::(source) @@ -164,7 +164,7 @@ mod tests { } #[rstest] - fn propagates_deserialisation_failures() { + fn propagates_deserialization_failures() { let source = "[module_max_lines]\nmax_lines = \"a lot\"\n"; let outcome: Result = toml::from_str(source); diff --git a/src/lints/template/validation.rs b/src/lints/template/validation.rs index 04f6a4ccd..66abaf442 100644 --- a/src/lints/template/validation.rs +++ b/src/lints/template/validation.rs @@ -97,23 +97,23 @@ pub(crate) fn lint_constant(crate_name: &str) -> String { .collect() } -fn capitalise_segment(segment: &str) -> Option { +fn capitalize_segment(segment: &str) -> Option { let mut characters = segment.chars(); let first = characters.next()?; - let mut capitalised = String::with_capacity(segment.len()); - capitalised.push(first.to_ascii_uppercase()); + let mut capitalized = String::with_capacity(segment.len()); + capitalized.push(first.to_ascii_uppercase()); for character in characters { - capitalised.push(character.to_ascii_lowercase()); + capitalized.push(character.to_ascii_lowercase()); } - Some(capitalised) + Some(capitalized) } pub(crate) fn pass_struct_name(crate_name: &str) -> String { crate_name .split(['-', '_']) - .filter_map(capitalise_segment) + .filter_map(capitalize_segment) .collect() } diff --git a/tests/config_loading.rs b/tests/config_loading.rs index cebb68eed..aac92dcd0 100644 --- a/tests/config_loading.rs +++ b/tests/config_loading.rs @@ -12,7 +12,7 @@ use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use support::locale::StepLocale; use whitaker::SharedConfig; -use whitaker_common::i18n::normalise_locale; +use whitaker_common::i18n::normalize_locale; /// Create empty configuration source state for one scenario. #[fixture] @@ -202,7 +202,7 @@ fn assert_locale( expected: StepLocale, ) { let raw = expected.into_inner(); - let Some(expected_value) = normalise_locale(Some(raw.as_str())) else { + let Some(expected_value) = normalize_locale(Some(raw.as_str())) else { panic!("expected the step to provide a locale value"); }; let config = match loaded_config(load_result) { diff --git a/tests/locale_resolution.rs b/tests/locale_resolution.rs index 6b07dd7e1..b06c12cfc 100644 --- a/tests/locale_resolution.rs +++ b/tests/locale_resolution.rs @@ -8,7 +8,7 @@ mod support; use rstest::fixture; use rstest_bdd_macros::{given, scenario, then, when}; use support::locale::StepLocale; -use whitaker_common::i18n::{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer}; +use whitaker_common::i18n::{LocaleSelection, LocaleSource, normalize_locale, resolve_localizer}; #[derive(Default)] struct LocaleWorld { @@ -18,6 +18,7 @@ struct LocaleWorld { resolution: RefCell>, } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn world() -> LocaleWorld { LocaleWorld::default() @@ -105,7 +106,7 @@ fn assert_source(world: &LocaleWorld, source: StepSource) { fn assert_locale(world: &LocaleWorld, value: StepLocale) { let resolution = resolved(world); let raw = value.into_inner(); - let expected = normalise_locale(Some(raw.as_str())) + let expected = normalize_locale(Some(raw.as_str())) .unwrap_or_else(|| panic!("expected the step to provide a locale value")); assert_eq!(resolution.locale(), expected); diff --git a/tests/ui_harness.rs b/tests/ui_harness.rs index a0e688d67..b442a3f71 100644 --- a/tests/ui_harness.rs +++ b/tests/ui_harness.rs @@ -57,6 +57,7 @@ impl Default for HarnessWorld { } } +#[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn harness_world() -> HarnessWorld { HarnessWorld::default() diff --git a/typos.local.toml b/typos.local.toml index daac599d9..52f60334b 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -13,91 +13,22 @@ accepted = [] [patterns] ignore = [ "(?m)^ \\.env_remove\\(env::CARGO_TERM_COLOR\\)$", - "(?m)^ \\.context\\(\"failed to serialise diagnostic for snapshot\"\\),$", - "(?m)^ Ok\\(parsed\\) => assert_eq!\\(artifact, parsed\\),$", - "(?m)^ \\.artifact_location$", - "(?m)^ artifact_location: ArtifactLocation \\{$", - "(?m)^ artifact_location: whitaker_sarif::ArtifactLocation \\{$", - "(?m)^ assert!\\(!json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\)\\);$", + # SARIF 2.1.0 (OASIS) spells these properties `artifactLocation` and + # `artifacts`. The Rust fields use the repository's Oxford spelling and pin + # the wire names with `serde(rename)`, so these two literals are serialized + # values fixed by the external schema, not prose. + "(?m)^ #\\[serde\\(rename = \"artifactLocation\"\\)\\]$", + "(?m)^ #\\[serde\\(default, rename = \"artifacts\", skip_serializing_if = \"Vec::is_empty\"\\)\\]$", "(?m)^ !json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", - "(?m)^ format!\\(\"bumpy\\-road signal rasterisation failed: \\{error\\}\"\\),$", - "(?m)^ Ok\\(json\\) => match serde_json::from_str::\\(&json\\) \\{$", - "(?m)^ \\.artifact_location$", - "(?m)^ \\.expect\\(\"Fluent source should be initialised\"\\);$", - "(?m)^ \\.unwrap_or_else\\(\\|\\| panic!\\(\"localizer should be initialised\"\\)\\)$", - "(?m)^ artifacts: Vec::new\\(\\),$", - "(?m)^ artifacts: self\\.artifacts,$", - "(?m)^ localised_messages\\(&FailingLookup::new\\(\"no_std_fs_operations\"\\), &op\\)$", - "(?m)^ localised_messages\\(localizer, &op\\)$", - "(?m)^ location: ArtifactLocation \\{$", - "(?m)^ location\\.physical_location\\.artifact_location\\.uri, file_uri,$", "(?m)^ \"\\-\\-artifact\\-server\\-path\",$", - "(?m)^ \"failed to initialise isolated rustup: \\{\\}\",$", - "(?m)^ TraitItemMetrics::default_method\\(\"serialise\", 8\\),$", - "(?m)^ \\.expect\\(\"failed to initialise isolated rustup environment\"\\);$", - "(?m)^ \\.expect\\(\"settings should be normalised\"\\);$", - "(?m)^ \\.filter_map\\(capitalise_segment\\)$", - "(?m)^ \\.normalised$", - "(?m)^ all_artifacts\\.extend\\(run\\.artifacts\\.clone\\(\\)\\);$", - "(?m)^ artifacts: all_artifacts,$", - "(?m)^ assert_eq!\\(loc\\.physical_location\\.artifact_location\\.uri, \"src/main\\.rs\"\\);$", - "(?m)^ assert_eq!\\(location\\.physical_location\\.artifact_location\\.uri, file_uri\\);$", - "(?m)^ assert_eq!\\(normalise_locale\\(input\\), expected\\);$", - "(?m)^ capitalised\\.push\\(character\\.to_ascii_lowercase\\(\\)\\);$", - "(?m)^ depth: normalise_weight\\(settings\\.weights\\.depth, defaults\\.weights\\.depth\\),$", - "(?m)^ flow: normalise_weight\\(settings\\.weights\\.flow, defaults\\.weights\\.flow\\),$", "(?m)^ let Message::CompilerArtifact\\(artefact\\) = message else \\{$", "(?m)^ let Ok\\(Message::CompilerArtifact\\(artefact\\)\\) = message else \\{$", - "(?m)^ let artifact = Artifact \\{$", - "(?m)^ let artefact = Artifact \\{$", - "(?m)^ let summary = summarise_context\\($", - "(?m)^ let summary = summarise_context\\(entries\\.as_slice\\(\\), has_test_context_ancestry, additional\\);$", - "(?m)^ let summary = summarise_context_with_harness\\($", - "(?m)^ localised_messages\\(&lookup, kind, attribute\\)$", - "(?m)^ match serde_json::to_string\\(&artifact\\) \\{$", - "(?m)^ normalise_locale\\(self\\.locale\\.as_deref\\(\\)\\)$", - "(?m)^ predicate: normalise_weight\\(settings\\.weights\\.predicate, defaults\\.weights\\.predicate\\),$", - "(?m)^ result\\.locations\\[0\\]\\.physical_location\\.artifact_location\\.uri,$", - "(?m)^ self\\.artifacts\\.push\\(artifact\\);$", - "(?m)^ self\\.settings = normalise_settings\\(load_configuration\\(\\)\\.into_settings\\(\\)\\);$", - "(?m)^ uses: actions/download\\-artifact@v4$", "(?m)^ uses: actions/upload\\-artifact@v4$", "(?m)^ uses: actions/upload\\-artifact@v7$", - "(?m)^ world\\.with_localizer\\(\\|localizer\\| localised_messages\\(localizer, kind, attribute\\)\\)$", - "(?m)^ artifact_dir = tmp_path / \"act\\-artifacts\"$", "(?m)^ \\- Download all artefacts \\(actions/download\\-artifact@v4,$", "(?m)^ \\- Upload artefact \\(actions/upload\\-artifact@v4,$", - "(?m)^ /// Appends an artifact reference\\.$", "(?m)^ /// Categorises the input with a single cluster of conditional logic\\.$", - "(?m)^ /// Identifies the artifact \\(file\\)\\.$", - "(?m)^ /// Location of the artifact\\.$", - "(?m)^ /// Optional byte offset from the start of the artifact\\.$", - "(?m)^ /// Optional region within the artifact\\.$", - "(?m)^ /// Referenced source artifacts\\.$", - "(?m)^ /// Relative or absolute URI of the artifact\\.$", - "(?m)^ /// Returns the source artifact URI used in SARIF output\\.$", - "(?m)^ /// Sets the byte offset from the start of the artifact\\.$", - "(?m)^ /// Sets the region within the artifact\\.$", - "(?m)^ /// assert_eq!\\(run\\.artifacts\\.len\\(\\), 1\\);$", "(?m)^ /// executed \\(catches mis\\-specified scenarios missing the When step\\)\\.$", - "(?m)^ And I normalise the settings$", - "(?m)^ And a default method normalise with CC 7$", - "(?m)^ Artifact, ArtifactLocation, Invocation, Level, Location, Message, MultiformatMessageString,$", - "(?m)^ ContextLabel, Localizer, NoExpectMessages, ReceiverCategory, ReceiverLabel, localised_messages,$", - "(?m)^ DEFAULT_THRESHOLD, Settings, Weights, detect_bumps, normalise_settings, top_two_bumps,$", - "(?m)^ Given the lint recognises custom::test as a test attribute$", - "(?m)^ Some\\(capitalised\\)$", - "(?m)^ Then the function is recognised as an rstest fixture$", - "(?m)^ Then the function is recognised as an rstest test$", - "(?m)^ Then the function is recognised as not being an rstest test$", - "(?m)^ Then the function is recognised as not test\\-like$", - "(?m)^ Then the function is recognised as test\\-like$", - "(?m)^ When I localise the diagnostic$", - "(?m)^ When I localise the expect diagnostic$", - "(?m)^ When I localise the std::fs diagnostic$", - "(?m)^ When I summarise the context$", - "(?m)^ When the complexity is finalised$", - "(?m)^ \\.contains\\(\"unrecognised boolean value 'maybe'\"\\)\\);$", "(?m)^ \\.help = Cuir beachd doc ris a tha aโ€™ mรฌneachadh giรนlan \\{ \\$function \\}\\.$", "(?m)^ \\.help = Cuir seachad `cap_std::fs::Dir` agus paramadairean `camino::Utf8Path`/`Utf8PathBuf` tron API seach std::fs a ghairm gu dรฌreach\\.$", "(?m)^ \\.label = Tha bump iom\\-fhillteachd \\{ \\$index \\} aโ€™ leudachadh thairis air \\{ \\$lines \\} \\{ \\$lines \\->$", @@ -109,157 +40,33 @@ ignore = [ "(?m)^ \\.note = Tha mรฒidealan mรฒra nas duilghe an ath\\-sgrรนdadh\\.$", "(?m)^ \\.note = Tha na docs airson \\{ \\$test \\} aโ€™ gabhail a\\-steach ceann eisimpleirean no bloca cรฒd le feansa\\.$", "(?m)^ \\.note = Tha teachdaireachdan airson \\{ \\$lint \\} ri fhaighinn ann an Gร idhlig\\.$", - "(?m)^ \\.note = The call originates within \\{ \\$context \\} which is not recognised as a test\\.$", "(?m)^ artefact: &cargo_metadata::Artifact,$", - "(?m)^ artifact_dir = tmp_path / \"act\\-artifacts\"$", - "(?m)^ artifacts: Vec,$", - "(?m)^ assert_eq!\\(normalised, expected\\);$", - "(?m)^ builder\\.add_default_method\\(\"normalise\", 7, false\\);$", - "(?m)^ capitalised\\.push\\(first\\.to_ascii_uppercase\\(\\)\\);$", - "(?m)^ context_label, fallback_messages, localised_messages,$", - "(?m)^ fn artifact_round_trip\\(\\) \\{$", - "(?m)^ fn deserialises_locale_override\\(\\) \\{$", - "(?m)^ fn deserialises_overrides_from_toml\\(\\) \\{$", - "(?m)^ fn normalise_weight\\(candidate: f64, fallback: f64\\) \\-> f64 \\{$", - "(?m)^ fn normalises_candidates\\(#\\[case\\] input: Option<&str>, #\\[case\\] expected: Option<&str>\\) \\{$", - "(?m)^ fn propagates_deserialisation_failures\\(\\) \\{$", - "(?m)^ fn success_message_pluralises_correctly\\(#\\[case\\] count: usize, #\\[case\\] expected: &str\\) \\{$", "(?m)^ let _ = fixture::categorise\\(0\\);$", "(?m)^ let _ = fixture::categorise\\(4\\);$", "(?m)^ let _ = fixture::categorise\\(99\\);$", - "(?m)^ let candidate = normalise_locale\\(raw\\)\\?;$", - "(?m)^ let expected = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\)$", - "(?m)^ let expected_value = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\)$", - "(?m)^ let Some\\(expected_value\\) = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\) else \\{$", - "(?m)^ let file = location\\.physical_location\\.artifact_location\\.uri\\.clone\\(\\);$", - "(?m)^ let help = normalise_isolation_marks\\(messages\\.help\\(\\)\\);$", - "(?m)^ let messages = localised_messages\\(&lookup, &receiver_label, &context_label, category\\)$", - "(?m)^ let mut all_artifacts = Vec::new\\(\\);$", - "(?m)^ let mut capitalised = String::with_capacity\\(segment\\.len\\(\\)\\);$", - "(?m)^ let mut summary = crate::context::summarise_context\\(cx, hir_id\\);$", - "(?m)^ let normalised = normalise_settings\\(settings\\);$", - "(?m)^ let normalized = normalise_settings\\(settings\\);$", - "(?m)^ let note = format!\\(\"The call originates within \\{context\\} which is not recognised as a test\\.\",\\);$", - "(?m)^ let note = normalise_isolation_marks\\(messages\\.note\\(\\)\\);$", - "(?m)^ let primary = normalise_isolation_marks\\(messages\\.primary\\(\\)\\);$", - "(?m)^ let summary = summarise_context\\(&entries, false, &\\[\\]\\);$", - "(?m)^ let summary = summarise_context\\(&entries, false, additional\\.as_slice\\(\\)\\);$", - "(?m)^ let summary = summarise_context\\(&entries, true, &\\[\\]\\);$", - "(?m)^ localised_messages,$", - "(?m)^ localised_messages\\(lookup, receiver, &context, category\\)$", - "(?m)^ normalise_locale, resolve_localizer, safe_resolve_message_set, supports_locale,$", - "(?m)^ normalised: RefCell>,$", - "(?m)^ pub artifact_location: ArtifactLocation,$", - "(?m)^ pub artifacts: Vec,$", "(?m)^ pub fn categorise\\(input: i32\\) \\-> &'static str \\{$", - "(?m)^ pub fn with_artifact\\(mut self, artifact: Artifact\\) \\-> Self \\{$", - "(?m)^ pub location: super::location::ArtifactLocation,$", "(?m)^ types: \\[opened, reopened, synchronize, labeled, ready_for_review\\]$", - "(?m)^ use crate::model::location::ArtifactLocation;$", - "(?m)^ validate_pluralisation_coverage\\(locale, max_branches\\);$", - "(?m)^ world\\.normalised\\.replace\\(Some\\(normalised\\)\\);$", "(?m)^ = help: Cuir seachad `cap_std::fs::Dir` agus paramadairean `camino::Utf8Path`/`Utf8PathBuf` tron API seach std::fs a ghairm gu dรฌreach\\.$", - "(?m)^ = note: The call originates within function `fail_result` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `handler` which is not recognised as a test\\.$", "(?m)^ = note: The call originates within function `helper` which is not recognised as a test\\.$", "(?m)^ = note: The call originates within function `load` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `main` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `parse_config` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `parse` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `process` which is not recognised as a test\\.$", "(?m)^ = note: The call originates within function `production` which is not recognised as a test\\.$", "(?m)^ CARGO_TERM_COLOR: always$", - "(?m)^ Scenario: Function recognised via configured attribute$", - "(?m)^ Scenario: Recognise configured custom test attribute$", - "(?m)^ Scenario: Recognise rstest decorated functions$", - "(?m)^ Scenario: Recognise tokio::test decorated functions$", - "(?m)^ Scenario: Type methods split into parsing, serialisation, and filesystem groups$", - "(?m)^ Whitaker lints require localised diagnostics with predictable fallbacks\\.$", "(?m)^ `download\\-artifact` merge step would clobber all but the last\\.$", - "(?m)^# Borrowed English nouns typically pluralise with \\-iau \\(Modern Welsh, Gareth$", - "(?m)^# Serialise dylint UI tests that build lint libraries and use$", - "(?m)^# Serialise ignored exclusion integration tests when they are explicitly run\\.$", - "(?m)^## Artifacts and Notes$", - "(?m)^## Artifacts and notes$", + "(?m)^# Borrowed English nouns typically pluralize with \\-iau \\(Modern Welsh, Gareth$", "(?m)^## Cuidhtearan breithneachaidh ga roinn thar linteran Whitaker\\.$", "(?m)^## Seachain `unwrap_or_else` a tha aโ€™ clisgeadh\\.$", "(?m)^## Tha `expect` toirmisgte taobh a\\-muigh deuchainnean\\.$", "(?m)^#\\. Air a shealltainn ann an breithneachaidhean nuair a tha e aโ€™ toirt iomradh air am buadh roimhe\\.$", - "(?m)^#\\[given\\(\"the lint recognises \\{path\\} as a test attribute\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as an rstest fixture\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as an rstest test\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as not being an rstest test\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as not test\\-like\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as test\\-like\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the expect diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the std::fs diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I normalise the settings\"\\)\\]$", - "(?m)^#\\[when\\(\"I summarise the context\"\\)\\]$", - "(?m)^#\\[when\\(\"the complexity is finalised\"\\)\\]$", - "(?m)^//! SARIF run, tool, invocation, and artifact types\\.$", - "(?m)^//! The fixture config recognises `#\\[tokio::test\\]`, but `parse_config` remains$", - "(?m)^/// artifacts: Vec::new\\(\\),$", - "(?m)^/// A location within an artifact \\(source file\\)\\.$", - "(?m)^/// A reference to an artifact by URI\\.$", - "(?m)^/// A region within an artifact, identified by line and column numbers\\.$", - "(?m)^/// A source artifact referenced by results\\.$", - "(?m)^/// and deduplicated\\. Artifacts and invocations are concatenated\\.$", - "(?m)^/// arbitrary `const`\\-only module for a synthesised rstest harness descriptor\\.$", - "(?m)^/// assert_eq!\\(artifact\\.location\\.uri, \"src/main\\.rs\"\\);$", - "(?m)^/// let artifact = Artifact \\{$", - "(?m)^Feature: Localised diagnostics for expect usage$", - "(?m)^Feature: Localised diagnostics for function attribute ordering$", - "(?m)^Feature: Localised diagnostics for std::fs usage$", - "(?m)^Feature: Localiser helpers$", - "(?m)^Feature: Summarise traversal context for `\\.expect\\(\\.\\.\\)` linting$", - "(?m)^\\- Helpers to build rules, results, locations, artifacts, and invocations\\.$", "(?m)^\\- Lexical segment matching can mis\\-handle renamed imports, glob imports, or$", + "(?m)^\\- \\*\\*Checkboxes:\\*\\* Precede task and sub\\-task items with a GitHub Flavored$", "(?m)^\\- `\\-\\-artifact\\-server\\-path `: export uploaded artefacts to a host$", "(?m)^bumpy_road_function = Tha iomadh cruinneachadh de loidsig chumhachail neadaichte ann an `\\{ \\$name \\}`\\.$", "(?m)^careful setup before a test can execute and, sometimes, teardown afterward\\.$", "(?m)^cargo dylint list \\-\\-color never \\| Where\\-Object \\{\\{$", "(?m)^cargo dylint list \\-\\-color never \\| awk \\-v suite=\"\\{suite_crate\\}\" '\\$0 ~ \"\\^\" suite \"\\(\\[\\[:space:\\]\\]\\|\\$\\)\" \\{\\{ print \\}\\}'$", "(?m)^error: Tha gnรฌomh std::fs `std::fs::read` aโ€™ seachnadh aโ€™ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", - "(?m)^fn capitalise_segment\\(segment: &str\\) \\-> Option \\{$", - "(?m)^fn localised_help_attributes_are_complete\\(\\) \\{$", - "(?m)^fn localised_messages\\($", - "(?m)^fn normalise_isolation_marks\\(text: &str\\) \\-> String \\{$", - "(?m)^fn normalise_settings_falls_back_to_defaults\\($", - "(?m)^fn pluralisation_covers_sample_range\\(#\\[case\\] locale: &str, #\\[case\\] max_branches: i64\\) \\{$", - "(?m)^fn recognises_std_fs_paths\\(#\\[case\\] path: &str, #\\[case\\] expected: bool\\) \\{$", - "(?m)^fn recognises_test_attribute\\(\\) \\{$", - "(?m)^fn resolve_additional_components_parametrised\\(#\\[case\\] cranelift: bool, #\\[case\\] expected: &\\[&str\\]\\) \\{$", - "(?m)^fn scenario_recognises_custom\\(function: FunctionFixture, evaluation: Evaluation\\) \\{$", - "(?m)^fn summarise_context_with_harness<'tcx>\\($", - "(?m)^fn summarises_plain_context\\(\\) \\{$", - "(?m)^fn validate_pluralisation_coverage\\(locale: &str, max_branches: i64\\) \\{$", - "(?m)^fn when_finalised\\(world: &CcWorld\\) \\{$", - "(?m)^fn when_localise\\(world: &WorldCell\\) \\{$", - "(?m)^fn when_normalise\\(world: &World\\) \\{$", - "(?m)^fn when_summarise\\(world: &ContextWorld\\) \\{$", "(?m)^module_max_lines = Tha mรฒideal \\{ \\$module \\} aโ€™ leudachadh gu \\{ \\$lines \\} loidhnichean agus aโ€™ briseadh an crรฌoch \\{ \\$limit \\}\\.$", "(?m)^no_std_fs_operations = Tha gnรฌomh std::fs `\\{ \\$operation \\}` aโ€™ seachnadh aโ€™ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", - "(?m)^pub fn normalise_locale\\(input: Option<&str>\\) \\-> Option<&str> \\{$", - "(?m)^pub fn normalise_settings\\(settings: Settings\\) \\-> Settings \\{$", - "(?m)^pub fn rasterise_signal\\($", - "(?m)^pub struct Artifact \\{$", - "(?m)^pub struct ArtifactLocation \\{$", - "(?m)^pub use location::\\{ArtifactLocation, Location, PhysicalLocation, Region, RelatedLocation\\};$", - "(?m)^pub use run::\\{Artifact, Invocation, Run, Tool, ToolComponent\\};$", - "(?m)^pub use selection::\\{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer\\};$", - "(?m)^pub\\(crate\\) fn localised_messages\\($", - "(?m)^pub\\(crate\\) fn summarise_context<'tcx>\\($", - "(?m)^pub\\(crate\\) fn summarise_context\\($", - "(?m)^use crate::analysis::\\{Settings, detect_bumps, normalise_settings\\};$", - "(?m)^use crate::context::\\{ContextSummary, summarise_context\\};$", - "(?m)^use crate::context::\\{collect_context, is_cfg_test_attribute, summarise_context\\};$", - "(?m)^use crate::context::summarise_context;$", - "(?m)^use crate::diagnostics::\\{StdFsMessages, localised_messages\\};$", - "(?m)^use crate::model::location::\\{ArtifactLocation, Location, PhysicalLocation, Region\\};$", - "(?m)^use crate::model::run::\\{Artifact, Invocation, Run, Tool, ToolComponent\\};$", - "(?m)^use whitaker_common::i18n::\\{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer\\};$", - "(?m)^use whitaker_common::i18n::normalise_locale;$", # Inline code spans are masked; the shared dictionary does not mask them yet. '`[^`\n]+`', ] From f692353563ad8ad76a43bd86ce67459734ee6f26 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 4 Sep 2026 18:41:58 +0200 Subject: [PATCH 02/32] Document installer dependency module layout Update the current installer dependency-module paths and record why `deps/mod.rs` owns the colocated fallback-policy implementation. --- docs/developers-guide.md | 4 ++-- docs/whitaker-dylint-suite-design.md | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index c4f9b7082..0085d2c8b 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -2421,7 +2421,7 @@ no longer implements `io::Write`, so neither `format!("{:x}", digest)` nor shared by `downloader.rs`, `artefact/packaging.rs`, and the `sha256_hex` test helper. -`installer/src/deps.rs` drives the high-level fallback order: +`installer/src/deps/mod.rs` drives the high-level fallback order: 1. Attempt the repository-hosted dependency archive for the current target. 2. Verify the installed tool is now usable. `cargo-dylint` is checked by @@ -2495,7 +2495,7 @@ A Cargo-managed `dylint-link` already on `PATH` is checked by resolving an executable file and comparing the version Cargo recorded for it, which needs no execution either. -The `dylint-link` verification in `installer/src/deps.rs` is implemented by +The `dylint-link` verification in `installer/src/deps/mod.rs` is implemented by five small private helpers: - `find_binary_on_path(binary_name)` returns the first executable candidate so diff --git a/docs/whitaker-dylint-suite-design.md b/docs/whitaker-dylint-suite-design.md index b5ba1b955..2d60ea7a1 100644 --- a/docs/whitaker-dylint-suite-design.md +++ b/docs/whitaker-dylint-suite-design.md @@ -1651,7 +1651,10 @@ used by the installer itself. - Extend `.github/workflows/release.yml` so tagged releases publish the same dependency-binary archives and provenance document next to the `whitaker-installer` archives. -- Teach `installer/src/deps.rs` to install missing tools in this order: +- Keep the dependency-tool installer in `installer/src/deps/mod.rs` so its + implementation children, including `install.rs`, remain colocated with the + module that owns the fallback policy. +- Teach `installer/src/deps/mod.rs` to install missing tools in this order: repository archive, `cargo binstall`, then `cargo install`. Repository-path failures stay non-fatal and degrade cleanly into the Cargo fallback path. From c9639a23e01a34a9882b659593a80120d6814eb7 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 4 Sep 2026 18:52:21 +0200 Subject: [PATCH 03/32] Document 0.3.0 API migration Guide library consumers through renamed public APIs and explain that SARIF wire keys retain their schema spelling. --- docs/contents.md | 2 + docs/users-guide.md | 9 +++ docs/v0-3-0-migration-guide.md | 133 +++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 docs/v0-3-0-migration-guide.md diff --git a/docs/contents.md b/docs/contents.md index 4c917f11e..0115566ba 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -7,6 +7,8 @@ - [User's guide](users-guide.md) explains how to install, configure, and use Whitaker and its lints. +- [Whitaker 0.3.0 migration guide](v0-3-0-migration-guide.md) lists the + source-breaking public API renames and upgrade mappings for library consumers. - [Developer's guide](developers-guide.md) explains how to build, test, verify, and extend Whitaker as a maintainer or contributor. - [Repository layout](repository-layout.md) maps the main directories, crates, diff --git a/docs/users-guide.md b/docs/users-guide.md index 34f803a98..8ddf4e0ce 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -6,6 +6,15 @@ explains how to integrate the lints into a project and configure them. For contributors who want to develop new lints or work on Whitaker itself, see the [Developer's Guide](developers-guide.md). +## Upgrading library consumers + +Version 0.3.0 includes source-breaking spelling changes to public Rust APIs. +Existing library consumers must update imports, field access, and builder calls +to the names listed in the +[Whitaker 0.3.0 migration guide](v0-3-0-migration-guide.md). The changes affect +Rust source only: SARIF 2.1.0 JSON keys retain their schema-defined spellings, +so existing serialized data requires no migration. + ## Quick Setup ### Prerequisites diff --git a/docs/v0-3-0-migration-guide.md b/docs/v0-3-0-migration-guide.md new file mode 100644 index 000000000..993839fdb --- /dev/null +++ b/docs/v0-3-0-migration-guide.md @@ -0,0 +1,133 @@ +# Whitaker 0.3.0 migration guide + +This guide covers source-breaking public API changes when upgrading library +consumers from the 0.2.x line to Whitaker 0.3.0. The release standardizes +Rust-facing identifiers on en-GB-oxendict spelling. Existing consumers must +update source references to the replacement names below; compatibility aliases +are not provided for the removed spellings. + +## Migration scope + +The changes affect Rust imports, type names, field access, and method calls. +They do not change the SARIF 2.1.0 serialized representation. Serde renames +keep the JSON keys `artifacts` and `artifactLocation`, so no serialized-data +migration is needed. + +## `whitaker_common` APIs + +Update the locale helper import and calls: + +```rust +use whitaker_common::i18n::normalise_locale; + +let locale = normalise_locale(Some("en-GB")); +``` + +becomes: + +```rust +use whitaker_common::i18n::normalize_locale; + +let locale = normalize_locale(Some("en-GB")); +``` + +The complete function mapping is: + +| 0.2.x name | 0.3.0 name | +| --- | --- | +| `normalise_locale` | `normalize_locale` | +| `rasterise_signal` | `rasterize_signal` | + +Update complexity-signal rasterization in the same way. The deprecated +`rasterise_signal` compatibility wrapper is removed; callers must use +`rasterize_signal`: + +```rust +use whitaker_common::complexity_signal::rasterize_signal; + +let signal = rasterize_signal(function_lines, segments)?; +``` + +## `bumpy_road_function` API + +Rename the settings normalizer at its import and call sites: + +```rust +use bumpy_road_function::analysis::{normalize_settings, Settings}; + +fn normalized(settings: Settings) -> Settings { + normalize_settings(settings) +} +``` + +The old `normalise_settings` function is replaced by `normalize_settings`. + +| 0.2.x name | 0.3.0 name | +| --- | --- | +| `normalise_settings` | `normalize_settings` | + +## `whitaker_sarif` APIs + +The SARIF model uses `Artefact` and `ArtefactLocation` for Rust identifiers. +Update type imports and values as follows: + +```rust +use whitaker_sarif::{Artefact, ArtefactLocation}; + +let artefact = Artefact { + location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + mime_type: Some("text/x-rust".into()), +}; +``` + +The complete type mapping is: + +| 0.2.x name | 0.3.0 name | +| ---------------------------------- | ---------------------------------- | +| `whitaker_sarif::Artifact` | `whitaker_sarif::Artefact` | +| `whitaker_sarif::ArtifactLocation` | `whitaker_sarif::ArtefactLocation` | + +Update renamed fields and builder methods at the same time: + +```rust +use whitaker_sarif::{Artefact, ArtefactLocation, PhysicalLocation, RunBuilder}; + +let physical_location = PhysicalLocation { + artefact_location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + region: None, +}; + +let run = RunBuilder::new("tool", "0.3.0") + .with_artefact(Artefact { + location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + mime_type: None, + }) + .build(); + +assert_eq!(run.artefacts.len(), 1); +assert_eq!(physical_location.artefact_location.uri, "src/main.rs"); +``` + +The field and method mappings are: + +| 0.2.x name | 0.3.0 name | +| ------------------------------------ | ------------------------------------ | +| `Run.artifacts` | `Run.artefacts` | +| `PhysicalLocation.artifact_location` | `PhysicalLocation.artefact_location` | +| `RunBuilder::with_artifact` | `.with_artefact` | + +The Rust names change while the SARIF wire names remain fixed. The +`Run.artefacts` field carries `#[serde(rename = "artifacts")]`, and the +`PhysicalLocation::artefact_location` field carries +`#[serde(rename = "artifactLocation")]`. Consequently, serialized JSON still +uses the SARIF 2.1.0 keys `artifacts` and `artifactLocation`; no update to +stored SARIF data or downstream SARIF readers is required. From 9222b5e1faa5e1f799a8a93bca920b6fbe9f8771 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 4 Sep 2026 19:16:34 +0200 Subject: [PATCH 04/32] Fix lint-hardening review findings Remove unconsumed usage-category state and inspect PATH candidates through capability-scoped filesystem metadata. Clarify module and guide documentation while retaining intentional cleanup and client-disconnect behaviour. --- .../conditional_max_n_branches/src/driver.rs | 11 +-- crates/no_std_fs_operations/src/driver.rs | 16 ++-- crates/no_std_fs_operations/src/usage/mod.rs | 74 +++++-------------- .../no_std_fs_operations/src/usage/tests.rs | 12 +-- .../installer-does-not-install-toolchain.md | 2 +- ...e-110-ambiguous-name-reported-by-dylint.md | 2 +- .../issue-94-dual-ownership-of-binary.md | 2 +- ...n-of-github-actions-with-act-and-pytest.md | 6 +- .../dependency_binaries/install/downloader.rs | 7 +- .../install/http_test_server.rs | 5 +- installer/src/deps/mod.rs | 32 +++++--- installer/src/deps/path_tests.rs | 21 +++++- 12 files changed, 83 insertions(+), 107 deletions(-) diff --git a/crates/conditional_max_n_branches/src/driver.rs b/crates/conditional_max_n_branches/src/driver.rs index e85419cfd..e798881c4 100644 --- a/crates/conditional_max_n_branches/src/driver.rs +++ b/crates/conditional_max_n_branches/src/driver.rs @@ -60,12 +60,13 @@ impl Default for ConditionalMaxNBranches { } } -/// Registration glue expanded from `dylint_linting::impl_late_lint!`. -/// -/// The macro generates undocumented public registration items, so the -/// expansion is scoped to this private module and only the documented lint -/// static is re-exported. mod registration { + //! Provides the public Dylint registration for this lint. + //! + //! The `dylint_linting::impl_late_lint!` macro generates undocumented + //! public registration items, so its expansion is scoped to this private + //! module and only the documented lint static is re-exported. + use super::ConditionalMaxNBranches; dylint_linting::impl_late_lint! { diff --git a/crates/no_std_fs_operations/src/driver.rs b/crates/no_std_fs_operations/src/driver.rs index 98515f4de..dfeb0ebe0 100644 --- a/crates/no_std_fs_operations/src/driver.rs +++ b/crates/no_std_fs_operations/src/driver.rs @@ -4,9 +4,7 @@ use crate::config::{LINT_NAME, load_configuration}; use crate::diagnostics::emit_diagnostic; use crate::exclusion::PathExclusions; -use crate::usage::{ - StdFsUsage, UsageCategory, classify_def_id, classify_qpath, classify_res, label_is_std_fs, -}; +use crate::usage::{StdFsUsage, classify_def_id, classify_qpath, classify_res, label_is_std_fs}; use log::{debug, info}; use rustc_hir as hir; use rustc_hir::AmbigArg; @@ -76,7 +74,7 @@ impl<'tcx> LateLintPass<'tcx> for NoStdFsOperations { span: path.span, }; for res in path.res.present_items() { - let usage = classify_res(cx, res, UsageCategory::Import); + let usage = classify_res(cx, res); self.emit_optional(cx, site, usage); } } @@ -92,18 +90,18 @@ impl<'tcx> LateLintPass<'tcx> for NoStdFsOperations { }; match &expr.kind { hir::ExprKind::Path(qpath) => { - let usage = classify_qpath(cx, qpath, expr.hir_id, UsageCategory::Call); + let usage = classify_qpath(cx, qpath, expr.hir_id); self.emit_optional(cx, site, usage); } hir::ExprKind::Struct(qpath, ..) => { - let usage = classify_qpath(cx, qpath, expr.hir_id, UsageCategory::Call); + let usage = classify_qpath(cx, qpath, expr.hir_id); self.emit_optional(cx, site, usage); } hir::ExprKind::MethodCall(segment, receiver, ..) => { let mut usage = cx .typeck_results() .type_dependent_def_id(expr.hir_id) - .and_then(|def_id| classify_def_id(cx, def_id, UsageCategory::Call)); + .and_then(|def_id| classify_def_id(cx, def_id)); if usage.is_none() { usage = self.receiver_usage_for_method(cx, receiver, segment.ident.as_str()); @@ -120,7 +118,7 @@ impl<'tcx> LateLintPass<'tcx> for NoStdFsOperations { return; } if let hir::TyKind::Path(qpath) = &ty.kind { - let usage = classify_qpath(cx, qpath, ty.hir_id, UsageCategory::Type); + let usage = classify_qpath(cx, qpath, ty.hir_id); self.emit_optional( cx, LintSite { @@ -204,7 +202,7 @@ impl NoStdFsOperations { } let operation = format!("{label}::{method}"); - Some(StdFsUsage::new(operation, UsageCategory::Call)) + Some(StdFsUsage::new(operation)) } } diff --git a/crates/no_std_fs_operations/src/usage/mod.rs b/crates/no_std_fs_operations/src/usage/mod.rs index f27902607..f91c4e580 100644 --- a/crates/no_std_fs_operations/src/usage/mod.rs +++ b/crates/no_std_fs_operations/src/usage/mod.rs @@ -7,35 +7,10 @@ use rustc_lint::LateContext; use rustc_span::sym; use whitaker_common::SimplePath; -/// Category describing how the `std::fs` item is being used. -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub enum UsageCategory { - /// `use std::fs::{..}` imports. - Import, - /// Type positions referencing `std::fs` types (structs, aliases). - Type, - /// Value-level calls, struct literals, or method invocations. - Call, -} - -impl UsageCategory { - /// Returns a stable &str identifier for use in tests and localization. - #[cfg(test)] - #[must_use] - pub const fn as_str(self) -> &'static str { - match self { - Self::Import => "import", - Self::Type => "type", - Self::Call => "call", - } - } -} - /// Normalized view of a `std::fs` operation for diagnostics and tests. #[derive(Clone, Debug, Eq, PartialEq)] pub struct StdFsUsage { operation: String, - category: UsageCategory, } impl StdFsUsage { @@ -45,15 +20,12 @@ impl StdFsUsage { /// # Examples /// /// ```ignore - /// # use crate::usage::{StdFsUsage, UsageCategory}; - /// let usage = StdFsUsage::new(String::from("std::fs::read"), UsageCategory::Call); + /// # use crate::usage::StdFsUsage; + /// let usage = StdFsUsage::new(String::from("std::fs::read")); /// assert_eq!(usage.operation(), "std::fs::read"); /// ``` - pub fn new(operation: String, category: UsageCategory) -> Self { - Self { - operation, - category, - } + pub fn new(operation: String) -> Self { + Self { operation } } /// Returns the fully qualified operation path (e.g., `std::fs::read`). @@ -62,20 +34,13 @@ impl StdFsUsage { /// # Examples /// /// ```ignore - /// # use crate::usage::{StdFsUsage, UsageCategory}; - /// let usage = StdFsUsage::new(String::from("std::fs::remove_file"), UsageCategory::Call); + /// # use crate::usage::StdFsUsage; + /// let usage = StdFsUsage::new(String::from("std::fs::remove_file")); /// assert_eq!(usage.operation(), "std::fs::remove_file"); /// ``` pub fn operation(&self) -> &str { &self.operation } - - /// Returns the usage category. - #[cfg(test)] - #[must_use] - pub const fn category(&self) -> UsageCategory { - self.category - } } /// Classify a resolved path (expression, type, import) into a usage record. @@ -86,19 +51,18 @@ impl StdFsUsage { /// ```ignore /// # use rustc_hir as hir; /// # use rustc_lint::LateContext; -/// # use crate::usage::{classify_qpath, UsageCategory}; +/// # use crate::usage::classify_qpath; /// # fn example<'tcx>(cx: &LateContext<'tcx>, qpath: &hir::QPath<'tcx>, hir_id: hir::HirId) { -/// let _ = classify_qpath(cx, qpath, hir_id, UsageCategory::Call); +/// let _ = classify_qpath(cx, qpath, hir_id); /// # } /// ``` pub fn classify_qpath( cx: &LateContext<'_>, qpath: &hir::QPath<'_>, hir_id: hir::HirId, - category: UsageCategory, ) -> Option { let res = cx.qpath_res(qpath, hir_id); - classify_res(cx, res, category) + classify_res(cx, res) } /// Classify using a `Res` obtained from HIR traversal. @@ -109,14 +73,14 @@ pub fn classify_qpath( /// ```ignore /// # use rustc_hir::def::Res; /// # use rustc_lint::LateContext; -/// # use crate::usage::{classify_res, UsageCategory}; +/// # use crate::usage::classify_res; /// # fn example<'tcx>(cx: &LateContext<'tcx>, res: Res) { -/// let _ = classify_res(cx, res, UsageCategory::Type); +/// let _ = classify_res(cx, res); /// # } /// ``` -pub fn classify_res(cx: &LateContext<'_>, res: Res, category: UsageCategory) -> Option { +pub fn classify_res(cx: &LateContext<'_>, res: Res) -> Option { res.opt_def_id() - .and_then(|def_id| classify_def_id(cx, def_id, category)) + .and_then(|def_id| classify_def_id(cx, def_id)) } /// Classify a `DefId` by inspecting its fully qualified path. @@ -127,23 +91,19 @@ pub fn classify_res(cx: &LateContext<'_>, res: Res, category: UsageCategory) -> /// ```ignore /// # use rustc_hir::def_id::DefId; /// # use rustc_lint::LateContext; -/// # use crate::usage::{classify_def_id, UsageCategory}; +/// # use crate::usage::classify_def_id; /// # fn example<'tcx>(cx: &LateContext<'tcx>, def_id: DefId) { -/// let _ = classify_def_id(cx, def_id, UsageCategory::Call); +/// let _ = classify_def_id(cx, def_id); /// # } /// ``` -pub fn classify_def_id( - cx: &LateContext<'_>, - def_id: DefId, - category: UsageCategory, -) -> Option { +pub fn classify_def_id(cx: &LateContext<'_>, def_id: DefId) -> Option { if cx.tcx.crate_name(def_id.krate) != sym::std { return None; } let label = cx.tcx.def_path_str(def_id); - label_is_std_fs(&label).then(|| StdFsUsage::new(label, category)) + label_is_std_fs(&label).then(|| StdFsUsage::new(label)) } fn is_std_fs_path(path: &SimplePath) -> bool { diff --git a/crates/no_std_fs_operations/src/usage/tests.rs b/crates/no_std_fs_operations/src/usage/tests.rs index 25114112b..a1d91044c 100644 --- a/crates/no_std_fs_operations/src/usage/tests.rs +++ b/crates/no_std_fs_operations/src/usage/tests.rs @@ -1,5 +1,5 @@ //! Tests for classifying std::fs usage and its reporting metadata. -use super::{StdFsUsage, UsageCategory, label_is_std_fs}; +use super::{StdFsUsage, label_is_std_fs}; use rstest::rstest; #[rstest] @@ -25,16 +25,8 @@ fn recognizes_std_fs_paths(#[case] path: &str, #[case] expected: bool) { assert_eq!(label_is_std_fs(path), expected); } -#[rstest] -fn usage_category_labels_are_stable() { - assert_eq!(UsageCategory::Import.as_str(), "import"); - assert_eq!(UsageCategory::Type.as_str(), "type"); - assert_eq!(UsageCategory::Call.as_str(), "call"); -} - #[rstest] fn usage_builder_carries_operation() { - let usage = StdFsUsage::new(String::from("std::fs::read"), UsageCategory::Call); + let usage = StdFsUsage::new(String::from("std::fs::read")); assert_eq!(usage.operation(), "std::fs::read"); - assert_eq!(usage.category(), UsageCategory::Call); } diff --git a/docs/execplans/installer-does-not-install-toolchain.md b/docs/execplans/installer-does-not-install-toolchain.md index 2c0ed3775..c24f48cec 100644 --- a/docs/execplans/installer-does-not-install-toolchain.md +++ b/docs/execplans/installer-does-not-install-toolchain.md @@ -186,7 +186,7 @@ already installed, no rustup install command should run. If rustup fails, the installer should return an error and can be re-run once the environment recovers. -## Artefacts and Notes +## Artefacts and notes Example log (expected after change): diff --git a/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md b/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md index 0e6eef526..7e3faa3bf 100644 --- a/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md +++ b/docs/execplans/issue-110-ambiguous-name-reported-by-dylint.md @@ -216,7 +216,7 @@ Edits are safe to re-run. If a step fails, revert file changes with Git and re-apply. Validation commands are safe to repeat. If validation fails twice, stop and escalate with the captured logs. -## Artefacts and Notes +## Artefacts and notes Keep the validation logs from `/tmp/whitaker-*.log` as evidence for each quality gate. Record any output from `cargo dylint list` that shows the updated diff --git a/docs/execplans/issue-94-dual-ownership-of-binary.md b/docs/execplans/issue-94-dual-ownership-of-binary.md index 0c1b8b19b..1b40d96d1 100644 --- a/docs/execplans/issue-94-dual-ownership-of-binary.md +++ b/docs/execplans/issue-94-dual-ownership-of-binary.md @@ -186,7 +186,7 @@ Edits are safe to re-run. If a step fails, revert the file changes with Git and re-apply. The `install-smoke` target uses a temporary directory and is safe to repeat. If validation fails twice, stop and escalate with the captured logs. -## Artefacts and Notes +## Artefacts and notes Capture key outputs in the log files listed in Concrete Steps. These logs are sufficient evidence for validation and troubleshooting. diff --git a/docs/local-validation-of-github-actions-with-act-and-pytest.md b/docs/local-validation-of-github-actions-with-act-and-pytest.md index be7114f65..7083dbed9 100644 --- a/docs/local-validation-of-github-actions-with-act-and-pytest.md +++ b/docs/local-validation-of-github-actions-with-act-and-pytest.md @@ -144,7 +144,7 @@ def run_act( def test_workflow_produces_expected_artefact_and_logs(tmp_path: Path) -> None: - artefact_dir = tmp_path / "act-artifacts" + artefact_dir = tmp_path / "act-artefacts" code, artdir, logs = run_act(artefact_dir=artefact_dir) assert code == 0, f"act failed:\n{logs}" @@ -214,7 +214,7 @@ loop: from cmd_mox import CmdMox def test_record(tmp_path: Path) -> None: - artefact_dir = tmp_path / "act-artifacts" + artefact_dir = tmp_path / "act-artefacts" with CmdMox() as mox: gh = mox.spy("gh").passthrough() mox.replay() @@ -229,7 +229,7 @@ loop: ```python def test_replay(tmp_path: Path, cmd_mox) -> None: - artefact_dir = tmp_path / "act-artifacts" + artefact_dir = tmp_path / "act-artefacts" cmd_mox.mock("gh").with_args( "release", "view", diff --git a/installer/src/dependency_binaries/install/downloader.rs b/installer/src/dependency_binaries/install/downloader.rs index 13923ac85..2b5d4d709 100644 --- a/installer/src/dependency_binaries/install/downloader.rs +++ b/installer/src/dependency_binaries/install/downloader.rs @@ -333,10 +333,9 @@ mod tests { impl Drop for ProbeCleanup { fn drop(&mut self) { - if self.dir.remove_file(&self.name).is_err() { - // A correct run never writes the probe into this directory, so - // a missing file is the expected case. - } + // A correct run never writes the probe into this directory, so a + // missing file is the expected case. + let _ = self.dir.remove_file(&self.name); } } diff --git a/installer/src/dependency_binaries/install/http_test_server.rs b/installer/src/dependency_binaries/install/http_test_server.rs index 46f486c23..e09c4f6c8 100644 --- a/installer/src/dependency_binaries/install/http_test_server.rs +++ b/installer/src/dependency_binaries/install/http_test_server.rs @@ -184,7 +184,6 @@ fn serve_connection( .write_all(header.as_bytes()) .and_then(|()| stream.write_all(body)) .and_then(|()| stream.flush()); - if write_result.is_err() { - // The client disconnected early; there is nothing further to serve. - } + // The client may disconnect early; there is nothing further to serve. + let _ = write_result; } diff --git a/installer/src/deps/mod.rs b/installer/src/deps/mod.rs index 4be663c2b..eb9aebeca 100644 --- a/installer/src/deps/mod.rs +++ b/installer/src/deps/mod.rs @@ -10,6 +10,8 @@ use crate::dependency_binaries::{ }; use crate::dirs::{BaseDirs, SystemBaseDirs}; use crate::error::{InstallerError, Result}; +use camino::Utf8Path; +use cap_std::{ambient_authority, fs_utf8::Dir}; use std::io; use std::io::Write; use std::path::Path; @@ -297,27 +299,31 @@ fn find_binary_on_path(binary_name: &str) -> Option { } fn find_binary_in_directory(directory: &Path, binary_name: &str) -> Option { - binary_candidates(directory, binary_name) + let directory = Utf8Path::from_path(directory)?; + let directory_capability = Dir::open_ambient_dir(directory, ambient_authority()).ok()?; + + binary_candidates(binary_name) .into_iter() - .find(|candidate| is_executable_file(candidate)) + .find(|candidate| is_executable_file(&directory_capability, Utf8Path::new(candidate))) + .map(|candidate| directory.join(candidate).into_std_path_buf()) } -fn binary_candidates(directory: &Path, binary_name: &str) -> Vec { +fn binary_candidates(binary_name: &str) -> Vec { #[cfg(windows)] let mut candidates = Vec::new(); #[cfg(not(windows))] - let candidates = vec![directory.join(binary_name)]; + let candidates = vec![binary_name.to_owned()]; #[cfg(windows)] { if Path::new(binary_name).extension().is_some() { - candidates.push(directory.join(binary_name)); + candidates.push(binary_name.to_owned()); } else { let lowercase_name = binary_name.to_ascii_lowercase(); candidates.extend( windows_path_extensions() .into_iter() .filter(|extension| !lowercase_name.ends_with(&extension.to_ascii_lowercase())) - .map(|extension| directory.join(format!("{binary_name}{extension}"))), + .map(|extension| format!("{binary_name}{extension}")), ); } } @@ -347,17 +353,21 @@ fn windows_path_extensions() -> Vec { } #[cfg(unix)] -fn is_executable_file(path: &Path) -> bool { - use std::os::unix::fs::PermissionsExt; +fn is_executable_file(directory: &Dir, path: &Utf8Path) -> bool { + use cap_std::fs::PermissionsExt; - std::fs::metadata(path) + directory + .metadata(path) .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) .unwrap_or(false) } #[cfg(not(unix))] -fn is_executable_file(path: &Path) -> bool { - path.is_file() +fn is_executable_file(directory: &Dir, path: &Utf8Path) -> bool { + directory + .metadata(path) + .map(|metadata| metadata.is_file()) + .unwrap_or(false) } #[cfg(test)] diff --git a/installer/src/deps/path_tests.rs b/installer/src/deps/path_tests.rs index 58655fc9c..4bfc74064 100644 --- a/installer/src/deps/path_tests.rs +++ b/installer/src/deps/path_tests.rs @@ -1,5 +1,7 @@ //! Tests for PATH-based dependency-binary discovery helpers. +use camino::Utf8Path; +use cap_std::{ambient_authority, fs_utf8::Dir}; use temp_env::with_vars_unset; use super::*; @@ -184,7 +186,16 @@ fn is_executable_file_rejects_non_executable_files() -> std::io::Result<()> { let binary_path = temp_dir.path().join("dylint-link"); write_fake_binary(&binary_path, false)?; - assert!(!is_executable_file(&binary_path)); + let directory = Dir::open_ambient_dir( + Utf8Path::from_path(temp_dir.path()).expect("temp path is UTF-8"), + ambient_authority(), + ) + .expect("open temp-dir capability"); + + assert!(!is_executable_file( + &directory, + Utf8Path::new("dylint-link") + )); Ok(()) } @@ -195,7 +206,13 @@ fn is_executable_file_accepts_executable_files() -> std::io::Result<()> { let binary_path = temp_dir.path().join("dylint-link"); write_fake_binary(&binary_path, true)?; - assert!(is_executable_file(&binary_path)); + let directory = Dir::open_ambient_dir( + Utf8Path::from_path(temp_dir.path()).expect("temp path is UTF-8"), + ambient_authority(), + ) + .expect("open temp-dir capability"); + + assert!(is_executable_file(&directory, Utf8Path::new("dylint-link"))); Ok(()) } From 37d230f7e47fe8af4b35fc12add2d8e26de7af5e Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 4 Sep 2026 19:32:21 +0200 Subject: [PATCH 05/32] Regenerate lockfile after rebase Resolve dependencies from the rebased manifests after keeping the target-side lockfile during conflict resolution. Format the accompanying 0.3.0 migration tables to satisfy the documentation formatter. --- docs/v0-3-0-migration-guide.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/v0-3-0-migration-guide.md b/docs/v0-3-0-migration-guide.md index 993839fdb..29cf41f55 100644 --- a/docs/v0-3-0-migration-guide.md +++ b/docs/v0-3-0-migration-guide.md @@ -33,8 +33,8 @@ let locale = normalize_locale(Some("en-GB")); The complete function mapping is: -| 0.2.x name | 0.3.0 name | -| --- | --- | +| 0.2.x name | 0.3.0 name | +| ------------------ | ------------------ | | `normalise_locale` | `normalize_locale` | | `rasterise_signal` | `rasterize_signal` | @@ -62,8 +62,8 @@ fn normalized(settings: Settings) -> Settings { The old `normalise_settings` function is replaced by `normalize_settings`. -| 0.2.x name | 0.3.0 name | -| --- | --- | +| 0.2.x name | 0.3.0 name | +| -------------------- | -------------------- | | `normalise_settings` | `normalize_settings` | ## `whitaker_sarif` APIs From bfa2899304c4748dede9d9caeddda9998354f59f Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 4 Sep 2026 19:38:59 +0200 Subject: [PATCH 06/32] Parameterize executable permission tests Keep both Unix permission cases while sharing their capability-scoped fixture setup. This removes the CodeScene duplication without suppressing the code-health rule. --- installer/src/deps/path_tests.rs | 36 +++++++++++--------------------- 1 file changed, 12 insertions(+), 24 deletions(-) diff --git a/installer/src/deps/path_tests.rs b/installer/src/deps/path_tests.rs index 4bfc74064..1978e8e6f 100644 --- a/installer/src/deps/path_tests.rs +++ b/installer/src/deps/path_tests.rs @@ -180,31 +180,16 @@ fn is_binary_on_path_checks_multiple_directories() -> std::io::Result<()> { } #[cfg(unix)] -#[test] -fn is_executable_file_rejects_non_executable_files() -> std::io::Result<()> { - let temp_dir = tempfile::tempdir().expect("create temp dir"); - let binary_path = temp_dir.path().join("dylint-link"); - write_fake_binary(&binary_path, false)?; - - let directory = Dir::open_ambient_dir( - Utf8Path::from_path(temp_dir.path()).expect("temp path is UTF-8"), - ambient_authority(), - ) - .expect("open temp-dir capability"); - - assert!(!is_executable_file( - &directory, - Utf8Path::new("dylint-link") - )); - Ok(()) -} - -#[cfg(unix)] -#[test] -fn is_executable_file_accepts_executable_files() -> std::io::Result<()> { +#[rstest::rstest] +#[case::non_executable(false, false)] +#[case::executable(true, true)] +fn is_executable_file_matches_executable_permission( + #[case] has_execute_permission: bool, + #[case] expected_is_executable: bool, +) -> std::io::Result<()> { let temp_dir = tempfile::tempdir().expect("create temp dir"); let binary_path = temp_dir.path().join("dylint-link"); - write_fake_binary(&binary_path, true)?; + write_fake_binary(&binary_path, has_execute_permission)?; let directory = Dir::open_ambient_dir( Utf8Path::from_path(temp_dir.path()).expect("temp path is UTF-8"), @@ -212,7 +197,10 @@ fn is_executable_file_accepts_executable_files() -> std::io::Result<()> { ) .expect("open temp-dir capability"); - assert!(is_executable_file(&directory, Utf8Path::new("dylint-link"))); + assert_eq!( + is_executable_file(&directory, Utf8Path::new("dylint-link")), + expected_is_executable, + ); Ok(()) } From 06faa31761c23581f6c97d47e428a09b0a7ae247 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 5 Sep 2026 02:00:13 +0200 Subject: [PATCH 07/32] Pin SARIF wire keys and harden PATH discovery Add direct serialization assertions for the SARIF 2.1.0 wire keys that the serde renames pin: PhysicalLocation must serialize artifactLocation and a populated Run must serialize artifacts. The existing round-trip tests are key-agnostic, so only these assertions catch a dropped rename. Caption the settings mapping table in the 0.3.0 migration guide to match the other mapping tables. Scan non-UTF-8 PATH entries when locating dependency binaries by opening cap_std::fs::Dir directly on each entry instead of routing through Utf8Path::from_path, which silently dropped such directories. Gate the cap-std test import behind cfg(unix) so Windows test builds stay free of unused-import warnings, and drop the camino import the new Dir-based helper no longer needs. Extend the typos exemptions for the two positive wire-key assertions the new tests introduce; the negative assertions reuse the existing pattern. --- crates/whitaker_sarif/src/model/location.rs | 28 +++++++++++++++++++++ crates/whitaker_sarif/src/model/run.rs | 24 ++++++++++++++++++ docs/v0-3-0-migration-guide.md | 2 ++ installer/src/deps/mod.rs | 18 ++++++------- installer/src/deps/path_tests.rs | 15 ++++++----- typos.local.toml | 2 ++ 6 files changed, 72 insertions(+), 17 deletions(-) diff --git a/crates/whitaker_sarif/src/model/location.rs b/crates/whitaker_sarif/src/model/location.rs index ccb0e81ff..96236a917 100644 --- a/crates/whitaker_sarif/src/model/location.rs +++ b/crates/whitaker_sarif/src/model/location.rs @@ -191,6 +191,34 @@ mod tests { assert_json_round_trip(&loc); } + #[test] + fn physical_location_serializes_sarif_wire_keys() { + let physical = PhysicalLocation { + artefact_location: ArtefactLocation { + uri: "src/lib.rs".into(), + uri_base_id: Some("%SRCROOT%".into()), + }, + region: Some(Region { + start_line: 5, + start_column: None, + end_line: None, + end_column: None, + byte_offset: None, + byte_length: None, + }), + }; + assert_serialized_json(&physical, |json| { + assert!( + json.contains("\"artifactLocation\""), + "SARIF artefact-location key missing: {json}" + ); + assert!( + !json.contains("\"artefactLocation\""), + "derived artefactLocation key present: {json}" + ); + }); + } + #[test] fn related_location_round_trip() { let rl = RelatedLocation { diff --git a/crates/whitaker_sarif/src/model/run.rs b/crates/whitaker_sarif/src/model/run.rs index 532b07ddd..f2746f3f6 100644 --- a/crates/whitaker_sarif/src/model/run.rs +++ b/crates/whitaker_sarif/src/model/run.rs @@ -153,6 +153,7 @@ mod tests { //! shared JSON assertion helpers. use super::*; + use crate::builders::RunBuilder; use crate::model::location::ArtefactLocation; use crate::test_support::{assert_json_round_trip, assert_serialized_json}; @@ -209,6 +210,29 @@ mod tests { }); } + #[test] + fn populated_run_serializes_artefacts_wire_key() { + let run = RunBuilder::new("test", "1.0") + .with_artefact(Artefact { + location: ArtefactLocation { + uri: "src/lib.rs".into(), + uri_base_id: Some("%SRCROOT%".into()), + }, + mime_type: Some("text/x-rust".into()), + }) + .build(); + assert_serialized_json(&run, |json| { + assert!( + json.contains("\"artifacts\""), + "SARIF artefact-list key missing: {json}" + ); + assert!( + !json.contains("\"artefacts\""), + "derived artefacts key present: {json}" + ); + }); + } + #[test] fn artefact_round_trip() { let artefact = Artefact { diff --git a/docs/v0-3-0-migration-guide.md b/docs/v0-3-0-migration-guide.md index 29cf41f55..49b440976 100644 --- a/docs/v0-3-0-migration-guide.md +++ b/docs/v0-3-0-migration-guide.md @@ -62,6 +62,8 @@ fn normalized(settings: Settings) -> Settings { The old `normalise_settings` function is replaced by `normalize_settings`. +The complete settings mapping is: + | 0.2.x name | 0.3.0 name | | -------------------- | -------------------- | | `normalise_settings` | `normalize_settings` | diff --git a/installer/src/deps/mod.rs b/installer/src/deps/mod.rs index eb9aebeca..b9bdd06eb 100644 --- a/installer/src/deps/mod.rs +++ b/installer/src/deps/mod.rs @@ -10,8 +10,7 @@ use crate::dependency_binaries::{ }; use crate::dirs::{BaseDirs, SystemBaseDirs}; use crate::error::{InstallerError, Result}; -use camino::Utf8Path; -use cap_std::{ambient_authority, fs_utf8::Dir}; +use cap_std::{ambient_authority, fs::Dir}; use std::io; use std::io::Write; use std::path::Path; @@ -299,13 +298,14 @@ fn find_binary_on_path(binary_name: &str) -> Option { } fn find_binary_in_directory(directory: &Path, binary_name: &str) -> Option { - let directory = Utf8Path::from_path(directory)?; + // `cap_std::fs::Dir` (not `fs_utf8`) keeps PATH entries that are not + // valid UTF-8 in the scan instead of silently dropping them. let directory_capability = Dir::open_ambient_dir(directory, ambient_authority()).ok()?; binary_candidates(binary_name) .into_iter() - .find(|candidate| is_executable_file(&directory_capability, Utf8Path::new(candidate))) - .map(|candidate| directory.join(candidate).into_std_path_buf()) + .find(|candidate| is_executable_file(&directory_capability, candidate)) + .map(|candidate| directory.join(candidate)) } fn binary_candidates(binary_name: &str) -> Vec { @@ -353,19 +353,19 @@ fn windows_path_extensions() -> Vec { } #[cfg(unix)] -fn is_executable_file(directory: &Dir, path: &Utf8Path) -> bool { +fn is_executable_file(directory: &Dir, candidate: &str) -> bool { use cap_std::fs::PermissionsExt; directory - .metadata(path) + .metadata(candidate) .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) .unwrap_or(false) } #[cfg(not(unix))] -fn is_executable_file(directory: &Dir, path: &Utf8Path) -> bool { +fn is_executable_file(directory: &Dir, candidate: &str) -> bool { directory - .metadata(path) + .metadata(candidate) .map(|metadata| metadata.is_file()) .unwrap_or(false) } diff --git a/installer/src/deps/path_tests.rs b/installer/src/deps/path_tests.rs index 1978e8e6f..4395b7173 100644 --- a/installer/src/deps/path_tests.rs +++ b/installer/src/deps/path_tests.rs @@ -1,7 +1,9 @@ //! Tests for PATH-based dependency-binary discovery helpers. -use camino::Utf8Path; -use cap_std::{ambient_authority, fs_utf8::Dir}; +// The cap-std imports are used only by the Unix executable-permission test; +// gating them keeps Windows test builds free of unused-import warnings. +#[cfg(unix)] +use cap_std::{ambient_authority, fs::Dir}; use temp_env::with_vars_unset; use super::*; @@ -191,14 +193,11 @@ fn is_executable_file_matches_executable_permission( let binary_path = temp_dir.path().join("dylint-link"); write_fake_binary(&binary_path, has_execute_permission)?; - let directory = Dir::open_ambient_dir( - Utf8Path::from_path(temp_dir.path()).expect("temp path is UTF-8"), - ambient_authority(), - ) - .expect("open temp-dir capability"); + let directory = Dir::open_ambient_dir(temp_dir.path(), ambient_authority()) + .expect("open temp-dir capability"); assert_eq!( - is_executable_file(&directory, Utf8Path::new("dylint-link")), + is_executable_file(&directory, "dylint-link"), expected_is_executable, ); Ok(()) diff --git a/typos.local.toml b/typos.local.toml index 52f60334b..9e49e9e62 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -20,6 +20,8 @@ ignore = [ "(?m)^ #\\[serde\\(rename = \"artifactLocation\"\\)\\]$", "(?m)^ #\\[serde\\(default, rename = \"artifacts\", skip_serializing_if = \"Vec::is_empty\"\\)\\]$", "(?m)^ !json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", + "(?m)^ json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", + "(?m)^ json\\.contains\\(\"\\\\\"artifactLocation\\\\\"\"\\),$", "(?m)^ \"\\-\\-artifact\\-server\\-path\",$", "(?m)^ let Message::CompilerArtifact\\(artefact\\) = message else \\{$", "(?m)^ let Ok\\(Message::CompilerArtifact\\(artefact\\)\\) = message else \\{$", From f1994ff3f6f1758b30a3a56acd9a958c054c31e5 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 8 Sep 2026 10:44:14 +0200 Subject: [PATCH 08/32] Ignore agent memory scratch directory Local agent tooling writes a memories/ progress file into the worktree. Rename the convention to .memories/ and ignore it so session scratch never shows up as an untracked change. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index f21851e33..79549a118 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,6 @@ htmlcov/ *.swp *.swo *~ + +# Session scratch produced by local agent tooling. +.memories/ From 284371b4e8a3a968ce34ae6c50fb278b74008234 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 27 Sep 2026 01:46:23 +0200 Subject: [PATCH 09/32] Complete spelling migration contracts (#397-401) Pin SARIF 2.1.0 wire names with fixed and generated JSON tests, and compile-check renamed public APIs from downstream fixtures. Exercise numeric saturation and span-conversion boundaries. Document changed helpers and capability-based installer PATH scanning, including bounded failure categories. Keep Markdown lint discovery aligned with formatting so ignored local agent scratch cannot fail repository gates. --- Makefile | 4 +- common/Cargo.toml | 1 + common/tests/public_api_contract.rs | 8 + common/tests/ui/legacy_common_apis.rs | 9 + common/tests/ui/legacy_common_apis.stderr | 23 +++ common/tests/ui/pass_renamed_common_apis.rs | 12 ++ crates/bumpy_road_function/Cargo.toml | 1 + crates/bumpy_road_function/src/analysis.rs | 1 + .../src/driver/diagnostic.rs | 25 ++- crates/bumpy_road_function/src/driver/mod.rs | 1 + .../bumpy_road_function/src/driver/numeric.rs | 46 +++++ .../src/driver/segment_builder.rs | 18 +- .../bumpy_road_function/tests/api_contract.rs | 8 + .../tests/api_ui/legacy_normalize_settings.rs | 7 + .../api_ui/legacy_normalize_settings.stderr | 11 + .../tests/api_ui/pass_normalize_settings.rs | 11 + .../tests/numeric_boundaries.rs | 4 + .../conditional_max_n_branches/src/driver.rs | 27 ++- .../src/driver/numeric.rs | 19 ++ .../tests/numeric_boundaries.rs | 4 + crates/whitaker_sarif/Cargo.toml | 2 + crates/whitaker_sarif/src/model/location.rs | 28 --- crates/whitaker_sarif/src/model/run.rs | 24 --- crates/whitaker_sarif/tests/api_contract.rs | 9 + .../tests/ui/legacy_sarif_api.rs | 13 ++ .../tests/ui/legacy_sarif_api.stderr | 30 +++ .../tests/ui/legacy_sarif_fields.rs | 22 ++ .../tests/ui/legacy_sarif_fields.stderr | 35 ++++ .../tests/ui/pass_renamed_sarif_api.rs | 25 +++ crates/whitaker_sarif/tests/wire_contracts.rs | 188 ++++++++++++++++++ .../tests/ui/fixture_expansion_lint.rs | 2 +- .../ui/fixture_expansion_lint_arguments.rs | 4 +- docs/developers-guide.md | 29 ++- docs/whitaker-dylint-suite-design.md | 11 +- installer/src/deps/mod.rs | 118 +++++++++-- installer/src/deps/path_tests.rs | 12 +- src/lints/template/validation.rs | 7 + 37 files changed, 702 insertions(+), 97 deletions(-) create mode 100644 common/tests/public_api_contract.rs create mode 100644 common/tests/ui/legacy_common_apis.rs create mode 100644 common/tests/ui/legacy_common_apis.stderr create mode 100644 common/tests/ui/pass_renamed_common_apis.rs create mode 100644 crates/bumpy_road_function/src/driver/numeric.rs create mode 100644 crates/bumpy_road_function/tests/api_contract.rs create mode 100644 crates/bumpy_road_function/tests/api_ui/legacy_normalize_settings.rs create mode 100644 crates/bumpy_road_function/tests/api_ui/legacy_normalize_settings.stderr create mode 100644 crates/bumpy_road_function/tests/api_ui/pass_normalize_settings.rs create mode 100644 crates/bumpy_road_function/tests/numeric_boundaries.rs create mode 100644 crates/conditional_max_n_branches/src/driver/numeric.rs create mode 100644 crates/conditional_max_n_branches/tests/numeric_boundaries.rs create mode 100644 crates/whitaker_sarif/tests/api_contract.rs create mode 100644 crates/whitaker_sarif/tests/ui/legacy_sarif_api.rs create mode 100644 crates/whitaker_sarif/tests/ui/legacy_sarif_api.stderr create mode 100644 crates/whitaker_sarif/tests/ui/legacy_sarif_fields.rs create mode 100644 crates/whitaker_sarif/tests/ui/legacy_sarif_fields.stderr create mode 100644 crates/whitaker_sarif/tests/ui/pass_renamed_sarif_api.rs create mode 100644 crates/whitaker_sarif/tests/wire_contracts.rs diff --git a/Makefile b/Makefile index 031c634d3..5838c9afa 100644 --- a/Makefile +++ b/Makefile @@ -147,7 +147,7 @@ WHITAKER_SCRIPT ?= $(HOME)/.local/bin/whitaker MD_FILES_FIND = find . \ \( -type d \( -name .git -o -name target -o -name .venv -o \ -name .uv-cache -o -name .uv-tools -o -name .pytest_cache -o \ - -name .vtcode -o -name memories -o -name node_modules \) -prune \) -o \ + -name .vtcode -o -name memories -o -name .memories -o -name node_modules \) -prune \) -o \ \( -type f -name '*.md' -print0 \) build: target/debug/$(APP) ## Build debug binary @@ -321,7 +321,7 @@ check-fmt: ## Verify formatting $(MDTABLEFIX) --check $(MDTABLEFIX_SELECT) $(MDTABLEFIX_RULES) markdownlint: spelling ## Lint Markdown files and enforce spelling - export PATH="$$PATH:$(TOOL_PATH_SUFFIX)"; $(MDLINT) '**/*.md' '!**/.uv-cache/**' '!**/.uv-tools/**' + export PATH="$$PATH:$(TOOL_PATH_SUFFIX)"; $(MD_FILES_FIND) | xargs -0 -r "$(MDLINT)" spelling: ## Enforce en-GB-oxendict in tracked text $(TYPOS_CONFIG_BUILDER) gate --repository . --scope all diff --git a/common/Cargo.toml b/common/Cargo.toml index 494c635b5..9fb097f83 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -26,6 +26,7 @@ unic-langid = { workspace = true } [dev-dependencies] cap-std = { workspace = true } whitaker_test_macros = { workspace = true } +trybuild = { workspace = true } # Testing: Behavioural specs use rstest-bdd 0.5.x (workspace pin). Capture any # regressions with `make test` and coordinate with the maintainers per # `docs/rstest-bdd-users-guide.md` before gating behaviour tests or backporting diff --git a/common/tests/public_api_contract.rs b/common/tests/public_api_contract.rs new file mode 100644 index 000000000..7eb6e238e --- /dev/null +++ b/common/tests/public_api_contract.rs @@ -0,0 +1,8 @@ +//! Compile-time contracts for common public APIs changed to Oxford spelling. + +#[test] +fn renamed_common_apis_are_public_and_legacy_names_stay_removed() { + let cases = trybuild::TestCases::new(); + cases.pass("tests/ui/pass_renamed_common_apis.rs"); + cases.compile_fail("tests/ui/legacy_common_apis.rs"); +} diff --git a/common/tests/ui/legacy_common_apis.rs b/common/tests/ui/legacy_common_apis.rs new file mode 100644 index 000000000..bd5e2f9fa --- /dev/null +++ b/common/tests/ui/legacy_common_apis.rs @@ -0,0 +1,9 @@ +//! Compile-fail contract for removed British-spelled common API names. + +use whitaker_common::complexity_signal::rasterise_signal; +use whitaker_common::normalise_locale; + +fn main() { + let _ = normalise_locale(Some("en-GB")); + let _ = rasterise_signal(1..=1, &[]); +} diff --git a/common/tests/ui/legacy_common_apis.stderr b/common/tests/ui/legacy_common_apis.stderr new file mode 100644 index 000000000..f285cc2ee --- /dev/null +++ b/common/tests/ui/legacy_common_apis.stderr @@ -0,0 +1,23 @@ +error[E0432]: unresolved import `whitaker_common::complexity_signal::rasterise_signal` + --> tests/ui/legacy_common_apis.rs:3:5 + | +3 | use whitaker_common::complexity_signal::rasterise_signal; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ no `rasterise_signal` in `complexity_signal` + | +help: a similar name exists in the module + | +3 - use whitaker_common::complexity_signal::rasterise_signal; +3 + use whitaker_common::complexity_signal::rasterize_signal; + | + +error[E0432]: unresolved import `whitaker_common::normalise_locale` + --> tests/ui/legacy_common_apis.rs:4:5 + | +4 | use whitaker_common::normalise_locale; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ no `normalise_locale` in the root + | +help: a similar name exists in the module + | +4 - use whitaker_common::normalise_locale; +4 + use whitaker_common::normalize_locale; + | diff --git a/common/tests/ui/pass_renamed_common_apis.rs b/common/tests/ui/pass_renamed_common_apis.rs new file mode 100644 index 000000000..a9d2a5a6b --- /dev/null +++ b/common/tests/ui/pass_renamed_common_apis.rs @@ -0,0 +1,12 @@ +//! Compile contract for Oxford-spelled common public APIs. + +use whitaker_common::complexity_signal::{LineSegment, rasterize_signal}; +use whitaker_common::normalize_locale; + +fn main() { + let locale = normalize_locale(Some(" en-GB ")); + let segments: Vec<_> = LineSegment::new(1, 1, 1.0).into_iter().collect(); + let signal = rasterize_signal(1..=1, &segments); + assert_eq!(locale, Some("en-GB")); + assert_eq!(signal.ok(), Some(vec![1.0])); +} diff --git a/crates/bumpy_road_function/Cargo.toml b/crates/bumpy_road_function/Cargo.toml index 3e9bff461..da578544c 100644 --- a/crates/bumpy_road_function/Cargo.toml +++ b/crates/bumpy_road_function/Cargo.toml @@ -52,3 +52,4 @@ whitaker = { workspace = true } whitaker-common = { workspace = true } camino = { workspace = true } tempfile = { workspace = true } +trybuild = { workspace = true } diff --git a/crates/bumpy_road_function/src/analysis.rs b/crates/bumpy_road_function/src/analysis.rs index 15e9401b9..d7aaabd44 100644 --- a/crates/bumpy_road_function/src/analysis.rs +++ b/crates/bumpy_road_function/src/analysis.rs @@ -89,6 +89,7 @@ impl Default for Settings { /// ``` #[must_use] pub fn normalize_settings(settings: Settings) -> Settings { + /// Keep a finite non-negative weight; otherwise use its default. fn normalize_weight(candidate: f64, fallback: f64) -> f64 { if candidate.is_finite() && candidate >= 0.0 { candidate diff --git a/crates/bumpy_road_function/src/driver/diagnostic.rs b/crates/bumpy_road_function/src/driver/diagnostic.rs index 265065111..bd17388f8 100644 --- a/crates/bumpy_road_function/src/driver/diagnostic.rs +++ b/crates/bumpy_road_function/src/driver/diagnostic.rs @@ -15,6 +15,7 @@ use whitaker_common::{ Arguments, Localizer, MessageResolution, noop_reporter, safe_resolve_message_set, }; +use super::numeric::{saturating_diagnostic_integer, span_byte_offset}; use super::{BUMPY_ROAD_FUNCTION, LINT_NAME, MESSAGE_KEY}; /// Payload describing a lint diagnostic to emit. @@ -27,6 +28,10 @@ pub(super) struct DiagnosticInput<'a> { pub(super) settings: &'a Settings, } +/// Emit the localized lint message and labels for the two most severe bumps. +/// +/// Missing source snippets omit the affected labels, and oversized numeric +/// arguments are saturated before they are passed to Fluent. pub(super) fn emit_diagnostic( cx: &LateContext<'_>, input: DiagnosticInput<'_>, @@ -36,7 +41,9 @@ pub(super) fn emit_diagnostic( args.insert(Cow::Borrowed("name"), FluentValue::from(input.name)); args.insert( Cow::Borrowed("count"), - FluentValue::from(i64::try_from(input.bumps.len()).unwrap_or(i64::MAX)), + FluentValue::from(saturating_diagnostic_integer( + u64::try_from(input.bumps.len()).unwrap_or(u64::MAX), + )), ); args.insert( Cow::Borrowed("threshold"), @@ -68,8 +75,12 @@ pub(super) fn emit_diagnostic( }; let label = resolve_bump_label( localizer, - i64::try_from(ordinal + 1).unwrap_or(i64::MAX), - i64::try_from(interval.len()).unwrap_or(i64::MAX), + saturating_diagnostic_integer( + u64::try_from(ordinal).unwrap_or(u64::MAX).saturating_add(1), + ), + saturating_diagnostic_integer( + u64::try_from(interval.len()).unwrap_or(u64::MAX), + ), ); lint.span_label(span, label); } @@ -137,6 +148,10 @@ impl LineSpanMapper { } } + /// Map an inclusive source-line range into the snippet's source span. + /// + /// Returns `None` when the start line is outside the snippet, the range is + /// reversed, or a byte offset cannot fit the compiler's `u32` positions. fn span_for_range(&self, start_line: usize, end_line: usize) -> Option { if start_line < self.base_line || end_line < start_line { return None; @@ -155,8 +170,8 @@ impl LineSpanMapper { let base = self.base_span.shrink_to_lo(); // `BytePos` is `u32`-backed; offsets beyond 4 GiB cannot be represented // as spans, so such (pathological) snippets yield no span. - let lo = base.lo() + BytePos(u32::try_from(start_offset).ok()?); - let mut hi = base.lo() + BytePos(u32::try_from(end_offset).ok()?); + let lo = base.lo() + BytePos(span_byte_offset(u64::try_from(start_offset).ok()?)?); + let mut hi = base.lo() + BytePos(span_byte_offset(u64::try_from(end_offset).ok()?)?); if hi <= lo { hi = lo + BytePos(1); } diff --git a/crates/bumpy_road_function/src/driver/mod.rs b/crates/bumpy_road_function/src/driver/mod.rs index e3afd6857..7171b108a 100644 --- a/crates/bumpy_road_function/src/driver/mod.rs +++ b/crates/bumpy_road_function/src/driver/mod.rs @@ -22,6 +22,7 @@ const MESSAGE_KEY: MessageKey<'static> = MessageKey::new(LINT_NAME); mod config; mod diagnostic; +mod numeric; mod segment_builder; use self::config::load_configuration; diff --git a/crates/bumpy_road_function/src/driver/numeric.rs b/crates/bumpy_road_function/src/driver/numeric.rs new file mode 100644 index 000000000..2d548cd36 --- /dev/null +++ b/crates/bumpy_road_function/src/driver/numeric.rs @@ -0,0 +1,46 @@ +//! Bounds-checked numeric conversions shared by the bumpy-road diagnostics. + +/// Clamp a diagnostic number to the largest value supported by Fluent. +pub(super) fn saturating_diagnostic_integer(value: u64) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} + +/// Clamp a branch count to the largest value representable by segment weights. +pub(super) fn saturating_branch_count(branches: u64) -> u32 { + u32::try_from(branches).unwrap_or(u32::MAX) +} + +/// Convert a snippet offset to the compiler's `u32` range, rejecting larger +/// values so the caller can omit an unrepresentable source span. +pub(super) fn span_byte_offset(offset: u64) -> Option { + u32::try_from(offset).ok() +} + +#[cfg(test)] +mod tests { + use super::{saturating_branch_count, saturating_diagnostic_integer, span_byte_offset}; + + #[test] + fn diagnostic_integers_saturate_at_the_signed_boundary() { + let maximum = i64::MAX.unsigned_abs(); + assert_eq!(saturating_diagnostic_integer(maximum - 1), i64::MAX - 1); + assert_eq!(saturating_diagnostic_integer(maximum), i64::MAX); + assert_eq!(saturating_diagnostic_integer(maximum + 1), i64::MAX); + } + + #[test] + fn branch_counts_saturate_at_the_unsigned_boundary() { + let maximum = u64::from(u32::MAX); + assert_eq!(saturating_branch_count(maximum - 1), u32::MAX - 1); + assert_eq!(saturating_branch_count(maximum), u32::MAX); + assert_eq!(saturating_branch_count(maximum + 1), u32::MAX); + } + + #[test] + fn span_offsets_reject_values_above_the_compiler_boundary() { + let maximum = u64::from(u32::MAX); + assert_eq!(span_byte_offset(maximum - 1), Some(u32::MAX - 1)); + assert_eq!(span_byte_offset(maximum), Some(u32::MAX)); + assert_eq!(span_byte_offset(maximum + 1), None); + } +} diff --git a/crates/bumpy_road_function/src/driver/segment_builder.rs b/crates/bumpy_road_function/src/driver/segment_builder.rs index 2b7f7ee44..68ab9eb9d 100644 --- a/crates/bumpy_road_function/src/driver/segment_builder.rs +++ b/crates/bumpy_road_function/src/driver/segment_builder.rs @@ -13,6 +13,8 @@ use rustc_span::source_map::SourceMap; use rustc_span::{DesugaringKind, Span}; use whitaker_common::complexity_signal::LineSegment; +use super::numeric::saturating_branch_count; + pub(super) struct SegmentBuilder<'a, 'tcx> { cx: &'a LateContext<'tcx>, settings: &'a Settings, @@ -134,12 +136,18 @@ impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { self.push_segment(span, self.settings.weights.flow); } + /// Add a predicate-weighted segment unless the condition is a `let` test. + /// + /// Branch counts above `u32::MAX` are saturated before conversion to the + /// floating-point weight; spans rejected by `push_segment` are omitted. fn push_predicate_segment(&mut self, expr: &'tcx hir::Expr<'tcx>) { if matches!(expr.kind, ExprKind::Let(..)) { return; } - let branches = f64::from(u32::try_from(count_branches(expr)).unwrap_or(u32::MAX)); + let branches = f64::from(saturating_branch_count( + u64::try_from(count_branches(expr)).unwrap_or(u64::MAX), + )); let value = branches * self.settings.weights.predicate; self.push_segment(expr.span, value); } @@ -206,6 +214,10 @@ fn extract_while_components<'hir>( } } +/// Count logical branches, treating `&&` and `||` as sums of their operands. +/// +/// Negation, blocks, and `if` expressions are transparent; every other +/// expression contributes one branch. fn count_branches(expr: &hir::Expr<'_>) -> usize { match expr.kind { ExprKind::Binary(op, lhs, rhs) if matches!(op.node, BinOpKind::And | BinOpKind::Or) => { @@ -218,6 +230,10 @@ fn count_branches(expr: &hir::Expr<'_>) -> usize { } } +/// Return the contiguous 1-based source-line range covered by a compiler span. +/// +/// Returns `None` when the span cannot be mapped to source lines or maps to a +/// non-contiguous set of lines. pub(super) fn span_line_range(source_map: &SourceMap, span: Span) -> Option> { let info = source_map.span_to_lines(span).ok()?; let first = info.lines.first()?; diff --git a/crates/bumpy_road_function/tests/api_contract.rs b/crates/bumpy_road_function/tests/api_contract.rs new file mode 100644 index 000000000..80f865442 --- /dev/null +++ b/crates/bumpy_road_function/tests/api_contract.rs @@ -0,0 +1,8 @@ +//! Compile-time contracts for the public settings-normalization API. + +#[test] +fn normalize_settings_is_public_and_the_legacy_name_stays_removed() { + let cases = trybuild::TestCases::new(); + cases.pass("tests/api_ui/pass_normalize_settings.rs"); + cases.compile_fail("tests/api_ui/legacy_normalize_settings.rs"); +} diff --git a/crates/bumpy_road_function/tests/api_ui/legacy_normalize_settings.rs b/crates/bumpy_road_function/tests/api_ui/legacy_normalize_settings.rs new file mode 100644 index 000000000..0cbb68d11 --- /dev/null +++ b/crates/bumpy_road_function/tests/api_ui/legacy_normalize_settings.rs @@ -0,0 +1,7 @@ +//! Compile-fail contract for the removed legacy settings function name. + +use bumpy_road_function::analysis::normalise_settings; + +fn main() { + let _ = normalise_settings(Default::default()); +} diff --git a/crates/bumpy_road_function/tests/api_ui/legacy_normalize_settings.stderr b/crates/bumpy_road_function/tests/api_ui/legacy_normalize_settings.stderr new file mode 100644 index 000000000..5074af3c7 --- /dev/null +++ b/crates/bumpy_road_function/tests/api_ui/legacy_normalize_settings.stderr @@ -0,0 +1,11 @@ +error[E0432]: unresolved import `bumpy_road_function::analysis::normalise_settings` + --> tests/api_ui/legacy_normalize_settings.rs:3:5 + | +3 | use bumpy_road_function::analysis::normalise_settings; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ no `normalise_settings` in `analysis` + | +help: a similar name exists in the module + | +3 - use bumpy_road_function::analysis::normalise_settings; +3 + use bumpy_road_function::analysis::normalize_settings; + | diff --git a/crates/bumpy_road_function/tests/api_ui/pass_normalize_settings.rs b/crates/bumpy_road_function/tests/api_ui/pass_normalize_settings.rs new file mode 100644 index 000000000..5e2b05e40 --- /dev/null +++ b/crates/bumpy_road_function/tests/api_ui/pass_normalize_settings.rs @@ -0,0 +1,11 @@ +//! Compile contract for the Oxford-spelled public settings API. + +// `make test` enables the Dylint driver for workspace dependencies. +#![feature(rustc_private)] + +use bumpy_road_function::analysis::{Settings, normalize_settings}; + +fn main() { + let settings = normalize_settings(Settings::default()); + assert!(settings.window > 0); +} diff --git a/crates/bumpy_road_function/tests/numeric_boundaries.rs b/crates/bumpy_road_function/tests/numeric_boundaries.rs new file mode 100644 index 000000000..3b2d8246f --- /dev/null +++ b/crates/bumpy_road_function/tests/numeric_boundaries.rs @@ -0,0 +1,4 @@ +//! Numeric conversion boundary contracts reused by the bumpy-road driver. + +#[path = "../src/driver/numeric.rs"] +mod numeric; diff --git a/crates/conditional_max_n_branches/src/driver.rs b/crates/conditional_max_n_branches/src/driver.rs index e798881c4..9ce1de79b 100644 --- a/crates/conditional_max_n_branches/src/driver.rs +++ b/crates/conditional_max_n_branches/src/driver.rs @@ -21,6 +21,9 @@ use whitaker_common::{ get_localizer_for_lint, noop_reporter, safe_resolve_message_set, }; +mod numeric; +use self::numeric::saturating_diagnostic_integer; + const LINT_NAME: &str = "conditional_max_n_branches"; const MESSAGE_KEY: MessageKey<'static> = MessageKey::new(LINT_NAME); @@ -196,6 +199,10 @@ impl ConditionKind { } } +/// Count logical branches, treating `&&` and `||` as sums of their operands. +/// +/// Negation, blocks, and `if` expressions are transparent; every other +/// expression contributes one branch. fn count_branches(expr: &hir::Expr<'_>) -> usize { match expr.kind { ExprKind::Binary(op, lhs, rhs) if matches!(op.node, BinOpKind::And | BinOpKind::Or) => { @@ -208,6 +215,10 @@ fn count_branches(expr: &hir::Expr<'_>) -> usize { } } +/// Emit the localized branch-count diagnostic for one condition. +/// +/// Counts are saturated to Fluent's signed 64-bit range, and missing +/// translations fall back to the built-in English message set. fn emit_diagnostic( cx: &LateContext<'_>, metadata: &ConditionMetadata, @@ -221,11 +232,15 @@ fn emit_diagnostic( ); args.insert( Cow::Borrowed("branches"), - FluentValue::from(i64::try_from(metadata.branches).unwrap_or(i64::MAX)), + FluentValue::from(saturating_diagnostic_integer( + u64::try_from(metadata.branches).unwrap_or(u64::MAX), + )), ); args.insert( Cow::Borrowed("limit"), - FluentValue::from(i64::try_from(limit).unwrap_or(i64::MAX)), + FluentValue::from(saturating_diagnostic_integer( + u64::try_from(limit).unwrap_or(u64::MAX), + )), ); let branch_phrase_text = branch_phrase(localizer.locale(), metadata.branches); args.insert( @@ -262,6 +277,10 @@ fn emit_diagnostic( ); } +/// Replace injected bidi isolates and replacement characters in diagnostics. +/// +/// Each such character becomes a double quote; text without these characters +/// is returned unchanged apart from allocating the owned result. fn normalize_isolation_marks(text: &str) -> String { if text .chars() @@ -278,6 +297,10 @@ fn normalize_isolation_marks(text: &str) -> String { } } +/// Build the built-in English diagnostic messages for a condition. +/// +/// The branch and limit values are rendered as locale-aware English phrases +/// when no translated message is available. fn fallback_messages(kind: ConditionKind, branches: usize, limit: usize) -> DiagnosticMessageSet { let branch_phrase_text = branch_phrase(FALLBACK_LOCALE, branches); let limit_phrase_text = branch_phrase(FALLBACK_LOCALE, limit); diff --git a/crates/conditional_max_n_branches/src/driver/numeric.rs b/crates/conditional_max_n_branches/src/driver/numeric.rs new file mode 100644 index 000000000..c4ae68976 --- /dev/null +++ b/crates/conditional_max_n_branches/src/driver/numeric.rs @@ -0,0 +1,19 @@ +//! Bounds-checked numeric conversions for conditional-branch diagnostics. + +/// Clamp diagnostic numbers to Fluent's signed 64-bit argument range. +pub(super) fn saturating_diagnostic_integer(value: u64) -> i64 { + i64::try_from(value).unwrap_or(i64::MAX) +} + +#[cfg(test)] +mod tests { + use super::saturating_diagnostic_integer; + + #[test] + fn diagnostic_integers_saturate_at_the_signed_boundary() { + let maximum = i64::MAX.unsigned_abs(); + assert_eq!(saturating_diagnostic_integer(maximum - 1), i64::MAX - 1); + assert_eq!(saturating_diagnostic_integer(maximum), i64::MAX); + assert_eq!(saturating_diagnostic_integer(maximum + 1), i64::MAX); + } +} diff --git a/crates/conditional_max_n_branches/tests/numeric_boundaries.rs b/crates/conditional_max_n_branches/tests/numeric_boundaries.rs new file mode 100644 index 000000000..48519ecf9 --- /dev/null +++ b/crates/conditional_max_n_branches/tests/numeric_boundaries.rs @@ -0,0 +1,4 @@ +//! Numeric conversion boundary contracts reused by the conditional driver. + +#[path = "../src/driver/numeric.rs"] +mod numeric; diff --git a/crates/whitaker_sarif/Cargo.toml b/crates/whitaker_sarif/Cargo.toml index 63ec0fce3..7086e155a 100644 --- a/crates/whitaker_sarif/Cargo.toml +++ b/crates/whitaker_sarif/Cargo.toml @@ -24,6 +24,8 @@ rstest = { workspace = true } rstest-bdd = { workspace = true } rstest-bdd-macros = { workspace = true } tempfile = { workspace = true } +proptest = { workspace = true } +trybuild = { workspace = true } [lints.clippy] expect_used = "deny" diff --git a/crates/whitaker_sarif/src/model/location.rs b/crates/whitaker_sarif/src/model/location.rs index 96236a917..ccb0e81ff 100644 --- a/crates/whitaker_sarif/src/model/location.rs +++ b/crates/whitaker_sarif/src/model/location.rs @@ -191,34 +191,6 @@ mod tests { assert_json_round_trip(&loc); } - #[test] - fn physical_location_serializes_sarif_wire_keys() { - let physical = PhysicalLocation { - artefact_location: ArtefactLocation { - uri: "src/lib.rs".into(), - uri_base_id: Some("%SRCROOT%".into()), - }, - region: Some(Region { - start_line: 5, - start_column: None, - end_line: None, - end_column: None, - byte_offset: None, - byte_length: None, - }), - }; - assert_serialized_json(&physical, |json| { - assert!( - json.contains("\"artifactLocation\""), - "SARIF artefact-location key missing: {json}" - ); - assert!( - !json.contains("\"artefactLocation\""), - "derived artefactLocation key present: {json}" - ); - }); - } - #[test] fn related_location_round_trip() { let rl = RelatedLocation { diff --git a/crates/whitaker_sarif/src/model/run.rs b/crates/whitaker_sarif/src/model/run.rs index f2746f3f6..532b07ddd 100644 --- a/crates/whitaker_sarif/src/model/run.rs +++ b/crates/whitaker_sarif/src/model/run.rs @@ -153,7 +153,6 @@ mod tests { //! shared JSON assertion helpers. use super::*; - use crate::builders::RunBuilder; use crate::model::location::ArtefactLocation; use crate::test_support::{assert_json_round_trip, assert_serialized_json}; @@ -210,29 +209,6 @@ mod tests { }); } - #[test] - fn populated_run_serializes_artefacts_wire_key() { - let run = RunBuilder::new("test", "1.0") - .with_artefact(Artefact { - location: ArtefactLocation { - uri: "src/lib.rs".into(), - uri_base_id: Some("%SRCROOT%".into()), - }, - mime_type: Some("text/x-rust".into()), - }) - .build(); - assert_serialized_json(&run, |json| { - assert!( - json.contains("\"artifacts\""), - "SARIF artefact-list key missing: {json}" - ); - assert!( - !json.contains("\"artefacts\""), - "derived artefacts key present: {json}" - ); - }); - } - #[test] fn artefact_round_trip() { let artefact = Artefact { diff --git a/crates/whitaker_sarif/tests/api_contract.rs b/crates/whitaker_sarif/tests/api_contract.rs new file mode 100644 index 000000000..73c9b8ed9 --- /dev/null +++ b/crates/whitaker_sarif/tests/api_contract.rs @@ -0,0 +1,9 @@ +//! Compile-time contracts for the externally exposed SARIF spelling changes. + +#[test] +fn renamed_sarif_api_is_public_and_legacy_names_stay_removed() { + let cases = trybuild::TestCases::new(); + cases.pass("tests/ui/pass_renamed_sarif_api.rs"); + cases.compile_fail("tests/ui/legacy_sarif_api.rs"); + cases.compile_fail("tests/ui/legacy_sarif_fields.rs"); +} diff --git a/crates/whitaker_sarif/tests/ui/legacy_sarif_api.rs b/crates/whitaker_sarif/tests/ui/legacy_sarif_api.rs new file mode 100644 index 000000000..cd2ca3b9a --- /dev/null +++ b/crates/whitaker_sarif/tests/ui/legacy_sarif_api.rs @@ -0,0 +1,13 @@ +//! Compile-fail contract for removed American-spelled SARIF API names. + +use whitaker_sarif::{Artifact, ArtifactLocation, RunBuilder}; + +fn main() { + let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artifact { + location: ArtifactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + mime_type: None, + }); +} diff --git a/crates/whitaker_sarif/tests/ui/legacy_sarif_api.stderr b/crates/whitaker_sarif/tests/ui/legacy_sarif_api.stderr new file mode 100644 index 000000000..3c749d700 --- /dev/null +++ b/crates/whitaker_sarif/tests/ui/legacy_sarif_api.stderr @@ -0,0 +1,30 @@ +error[E0432]: unresolved imports `whitaker_sarif::Artifact`, `whitaker_sarif::ArtifactLocation` + --> tests/ui/legacy_sarif_api.rs:3:22 + | +3 | use whitaker_sarif::{Artifact, ArtifactLocation, RunBuilder}; + | ^^^^^^^^ ^^^^^^^^^^^^^^^^ no `ArtifactLocation` in the root + | | + | no `Artifact` in the root + | +help: a similar name exists in the module + | +3 - use whitaker_sarif::{Artifact, ArtifactLocation, RunBuilder}; +3 + use whitaker_sarif::{Artefact, ArtifactLocation, RunBuilder}; + | +help: a similar name exists in the module + | +3 - use whitaker_sarif::{Artifact, ArtifactLocation, RunBuilder}; +3 + use whitaker_sarif::{Artifact, ArtefactLocation, RunBuilder}; + | + +error[E0599]: no method named `with_artifact` found for struct `RunBuilder` in the current scope + --> tests/ui/legacy_sarif_api.rs:6:50 + | +6 | let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artifact { + | -------------------------------------^^^^^^^^^^^^^ + | +help: there is a method `with_artefact` with a similar name + | +6 - let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artifact { +6 + let _ = RunBuilder::new("whitaker", "0.3.0").with_artefact(Artifact { + | diff --git a/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.rs b/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.rs new file mode 100644 index 000000000..889004e6a --- /dev/null +++ b/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.rs @@ -0,0 +1,22 @@ +//! Compile-fail contract for removed SARIF model and builder field spellings. + +use whitaker_sarif::{Artefact, ArtefactLocation, PhysicalLocation, RunBuilder}; + +fn main() { + let _location = PhysicalLocation { + artifact_location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + region: None, + }; + let run = RunBuilder::new("whitaker", "0.3.0").build(); + let _ = run.artifacts; + let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artefact { + location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + mime_type: None, + }); +} diff --git a/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.stderr b/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.stderr new file mode 100644 index 000000000..80eb8bb02 --- /dev/null +++ b/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.stderr @@ -0,0 +1,35 @@ +error[E0560]: struct `PhysicalLocation` has no field named `artifact_location` + --> tests/ui/legacy_sarif_fields.rs:7:9 + | +7 | artifact_location: ArtefactLocation { + | ^^^^^^^^^^^^^^^^^ unknown field + | +help: a field with a similar name exists + | +7 - artifact_location: ArtefactLocation { +7 + artefact_location: ArtefactLocation { + | + +error[E0609]: no field `artifacts` on type `Run` + --> tests/ui/legacy_sarif_fields.rs:14:17 + | +14 | let _ = run.artifacts; + | ^^^^^^^^^ unknown field + | +help: a field with a similar name exists + | +14 - let _ = run.artifacts; +14 + let _ = run.artefacts; + | + +error[E0599]: no method named `with_artifact` found for struct `RunBuilder` in the current scope + --> tests/ui/legacy_sarif_fields.rs:15:50 + | +15 | let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artefact { + | -------------------------------------^^^^^^^^^^^^^ + | +help: there is a method `with_artefact` with a similar name + | +15 - let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artefact { +15 + let _ = RunBuilder::new("whitaker", "0.3.0").with_artefact(Artefact { + | diff --git a/crates/whitaker_sarif/tests/ui/pass_renamed_sarif_api.rs b/crates/whitaker_sarif/tests/ui/pass_renamed_sarif_api.rs new file mode 100644 index 000000000..905524400 --- /dev/null +++ b/crates/whitaker_sarif/tests/ui/pass_renamed_sarif_api.rs @@ -0,0 +1,25 @@ +//! Compile contract for the public Oxford-spelled SARIF model and builder API. + +use whitaker_sarif::{Artefact, ArtefactLocation, PhysicalLocation, RunBuilder}; + +fn main() { + let artefact = Artefact { + location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + mime_type: None, + }; + let run = RunBuilder::new("whitaker", "0.3.0") + .with_artefact(artefact) + .build(); + let location = PhysicalLocation { + artefact_location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: None, + }, + region: None, + }; + assert_eq!(run.artefacts.len(), 1); + assert_eq!(location.artefact_location.uri, "src/main.rs"); +} diff --git a/crates/whitaker_sarif/tests/wire_contracts.rs b/crates/whitaker_sarif/tests/wire_contracts.rs new file mode 100644 index 000000000..632fffcc0 --- /dev/null +++ b/crates/whitaker_sarif/tests/wire_contracts.rs @@ -0,0 +1,188 @@ +//! Regression and property coverage for the SARIF 2.1.0 JSON boundary. + +use proptest::prelude::*; +use serde_json::{Value, json}; +use whitaker_sarif::{Artefact, ArtefactLocation, PhysicalLocation, Region, Run, RunBuilder}; + +/// Generate varied relative Rust-source URIs for serialization properties. +fn uri_strategy() -> impl Strategy { + proptest::collection::vec(b'a'..=b'z', 1..32).prop_map(|bytes| { + let path: String = bytes.into_iter().map(char::from).collect(); + format!("src/{path}.rs") + }) +} + +/// Check the canonical physical-location key and reject Rust-name variants. +fn assert_physical_location_wire_keys(json: &Value) { + assert!(json.get("artifactLocation").is_some()); + assert!(json.get("artefactLocation").is_none()); + assert!(json.get("artefact_location").is_none()); +} + +/// Check the canonical run artefact key and reject the Rust field spelling. +fn assert_run_wire_keys(json: &Value) { + assert!(json.get("artifacts").is_some()); + assert!(json.get("artefacts").is_none()); +} + +#[test] +fn populated_physical_location_uses_the_canonical_sarif_property() -> Result<(), serde_json::Error> +{ + let physical = PhysicalLocation { + artefact_location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: Some("%SRCROOT%".into()), + }, + region: Some(Region { + start_line: 12, + start_column: Some(4), + end_line: Some(14), + end_column: Some(19), + byte_offset: Some(128), + byte_length: Some(64), + }), + }; + + let serialized = serde_json::to_value(&physical)?; + assert_physical_location_wire_keys(&serialized); + assert_eq!( + serialized["artifactLocation"], + json!({"uri": "src/main.rs", "uriBaseId": "%SRCROOT%"}) + ); + + let canonical = json!({ + "artifactLocation": {"uri": "src/fixed.rs", "uriBaseId": "%SRCROOT%"}, + "region": {"startLine": 7, "endLine": 8} + }); + let decoded: PhysicalLocation = serde_json::from_value(canonical.clone())?; + assert_eq!(decoded.artefact_location.uri, "src/fixed.rs"); + assert_eq!( + decoded.artefact_location.uri_base_id.as_deref(), + Some("%SRCROOT%") + ); + assert_eq!( + decoded.region.as_ref().map(|region| region.start_line), + Some(7) + ); + assert_eq!(serde_json::to_value(decoded)?, canonical); + Ok(()) +} + +#[test] +fn populated_run_builder_preserves_the_canonical_sarif_property() -> Result<(), serde_json::Error> { + let artefact = Artefact { + location: ArtefactLocation { + uri: "src/main.rs".into(), + uri_base_id: Some("%SRCROOT%".into()), + }, + mime_type: Some("text/x-rust".into()), + }; + let run = RunBuilder::new("whitaker", "0.3.0") + .with_artefact(artefact.clone()) + .build(); + assert_eq!(run.artefacts, vec![artefact]); + + let serialized = serde_json::to_value(&run)?; + assert_run_wire_keys(&serialized); + assert_eq!(serialized["artifacts"][0]["location"]["uri"], "src/main.rs"); + + let canonical = json!({ + "tool": {"driver": {"name": "whitaker", "version": "0.3.0"}}, + "artifacts": [{ + "location": {"uri": "src/fixed.rs", "uriBaseId": "%SRCROOT%"}, + "mimeType": "text/x-rust" + }] + }); + let decoded: Run = serde_json::from_value(canonical.clone())?; + assert_eq!( + decoded.artefacts, + vec![Artefact { + location: ArtefactLocation { + uri: "src/fixed.rs".into(), + uri_base_id: Some("%SRCROOT%".into()), + }, + mime_type: Some("text/x-rust".into()), + }] + ); + assert_eq!(serde_json::to_value(decoded)?, canonical); + Ok(()) +} + +proptest! { + #[test] + fn generated_physical_locations_keep_the_canonical_wire_key( + uri in uri_strategy(), + uri_base_id in prop::option::of(uri_strategy()), + region in prop::option::of((1_usize..=1000, prop::option::of(1_usize..=500)).prop_map(|(start_line, start_column)| Region { + start_line, + start_column, + end_line: None, + end_column: None, + byte_offset: None, + byte_length: None, + })), + ) { + let physical = PhysicalLocation { + artefact_location: ArtefactLocation { uri, uri_base_id }, + region, + }; + let serialized = serde_json::to_value(&physical); + prop_assert!(serialized.is_ok()); + let serialized = serialized.unwrap_or(Value::Null); + prop_assert!(serialized.get("artifactLocation").is_some()); + prop_assert!(serialized.get("artefactLocation").is_none()); + prop_assert!(serialized.get("artefact_location").is_none()); + + let decoded = serde_json::from_value::(serialized.clone()); + prop_assert!(decoded.is_ok()); + let decoded = decoded.unwrap_or_else(|_| physical.clone()); + prop_assert_eq!(decoded.clone(), physical); + let round_trip = serde_json::to_value(decoded); + prop_assert!(round_trip.is_ok()); + let round_trip = round_trip.unwrap_or(Value::Null); + prop_assert_eq!(round_trip, serialized); + } + + #[test] + fn generated_runs_keep_the_canonical_wire_key_and_round_trip( + artefacts in prop::collection::vec( + (uri_strategy(), prop::option::of(uri_strategy()), prop::option::of(any::()).prop_map(|value| value.map(|_| "text/x-rust".to_owned()))), + 1..5 + ), + ) { + let expected_artefacts: Vec = artefacts + .into_iter() + .map(|(uri, uri_base_id, mime_type)| Artefact { + location: ArtefactLocation { uri, uri_base_id }, + mime_type, + }) + .collect(); + let run = expected_artefacts + .iter() + .cloned() + .fold(RunBuilder::new("whitaker", "0.3.0"), |builder, artefact| { + builder.with_artefact(artefact) + }) + .build(); + prop_assert!(!run.artefacts.is_empty()); + + let serialized = serde_json::to_value(&run); + prop_assert!(serialized.is_ok()); + let serialized = serialized.unwrap_or(Value::Null); + prop_assert!(serialized.get("artifacts").is_some()); + prop_assert!(serialized.get("artefacts").is_none()); + prop_assert_eq!( + serialized["artifacts"].as_array().map(|artefacts| artefacts.len()), + Some(run.artefacts.len()) + ); + + let decoded = serde_json::from_value::(serialized.clone()); + prop_assert!(decoded.is_ok()); + let decoded = decoded.unwrap_or_else(|_| run.clone()); + prop_assert_eq!(decoded.artefacts.as_slice(), expected_artefacts.as_slice()); + let round_trip = serde_json::to_value(decoded); + prop_assert!(round_trip.is_ok()); + let round_trip = round_trip.unwrap_or(Value::Null); + prop_assert_eq!(round_trip, serialized); + } +} diff --git a/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint.rs b/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint.rs index d1f1568c2..e04ee6b19 100644 --- a/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint.rs +++ b/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint.rs @@ -1,4 +1,4 @@ -//! Passing trybuild fixture proving the fixture-expansion lint attribute works +//! Passing trybuild fixture verifying suppression of fixture-expansion lints //! with a denied `unused_braces` lint. //! //! The fixture applies `allow_fixture_expansion_lints` to the small function diff --git a/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint_arguments.rs b/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint_arguments.rs index 132fb1510..5f695c3bd 100644 --- a/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint_arguments.rs +++ b/crates/whitaker_test_macros/tests/ui/fixture_expansion_lint_arguments.rs @@ -1,5 +1,5 @@ -//! Compile-fail trybuild fixture proving the fixture-expansion lint attribute -//! rejects unexpected arguments. +//! Compile-fail trybuild fixture verifying rejection of invalid +//! `allow_fixture_expansion_lints` arguments. //! //! The invalid invocation exercises the diagnostic contract for //! `allow_fixture_expansion_lints` while keeping the failure isolated from the diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 0085d2c8b..76cacccfe 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -2500,13 +2500,28 @@ five small private helpers: - `find_binary_on_path(binary_name)` returns the first executable candidate so the installation check can validate the exact path it found. -- `find_binary_in_directory(directory, binary_name)` performs the per-directory - search that `find_binary_on_path()` uses while walking `PATH`. -- `binary_candidates(directory, binary_name)` builds the ordered set of - candidate paths that each directory contributes to the lookup. -- `is_executable_file(path)` applies the platform-specific file test: - executable-bit plus regular-file checks on Unix, and `path.is_file()` on - non-Unix targets where the executable suffix carries the meaning. +- `find_binary_in_directory(directory, binary_name)` converts each PATH entry + to `camino::Utf8Path`, opens it as a `cap_std::fs_utf8::Dir`, and checks + candidates relative to that directory capability. Non-UTF-8 entries and + directories that cannot be opened are skipped, preserving the search order. +- `binary_candidates(binary_name: &str) -> Vec` returns candidate + executable names in PATH-search order; on Windows it expands extensionless + names using `PATHEXT`. +- `is_executable_file` has this signature: + + ```rust + fn is_executable_file( + directory: &Dir, + candidate: &Utf8Path, + binary_name: &str, + ) -> bool + ``` + + It obtains metadata with `Dir::metadata`, requires a regular file and, on + Unix, at least one executable permission bit. Metadata errors return `false`. + Failure traces include the binary name and a bounded failure category, but + not the PATH entry or candidate path. The executable suffix carries the + meaning on non-Unix targets. - `windows_path_extensions()` normalizes `PATHEXT` on Windows so `binary_candidates()` can expand extensionless names the same way the shell does. diff --git a/docs/whitaker-dylint-suite-design.md b/docs/whitaker-dylint-suite-design.md index 2d60ea7a1..1fa19edf4 100644 --- a/docs/whitaker-dylint-suite-design.md +++ b/docs/whitaker-dylint-suite-design.md @@ -731,10 +731,9 @@ qualified paths (`std::fs::read_to_string`) and items pulled in via into a `SimplePath` parser. A match requires the first two segments to equal `["std", "fs"]`. Re-exports remain covered because the resolved `DefId` points back to `std`. -- Each finding produces a `StdFsUsage` struct containing the resolved operation - label (for diagnostics) and a category (`Import`, `Type`, or `Call`). The - category drives unit tests and future config knobs but diagnostics currently - emphasize the operation name only to keep translations succinct. +- Each finding produces a `StdFsUsage` value that stores only the resolved + operation label. Classifiers return operation-only usage values, and + diagnostics use that label to keep translations succinct. - Diagnostics reuse Fluent bundles (`no_std_fs_operations.ftl`) with the operation label supplied via `{ $operation }`. The note explains why ambient access is disallowed and the help recommends `cap_std::fs` + `camino`. @@ -813,8 +812,8 @@ sequenceDiagram - Unit tests cover the operation classifier, ensuring paths such as `std::fs::File::open`, `std::fs::remove_file`, bare module references, and - renamed imports all register as `StdFsUsage` while `cap_std::fs` remains - untouched. + renamed imports all produce operation-only usage values while `cap_std::fs` + remains untouched. - Behaviour-driven tests (via `rstest-bdd 0.5.0`) exercise localization, failure fallbacks, and the world state used to model capability hints. Scenarios cover en-GB, cy, and gd locales, as well as missing-message paths. diff --git a/installer/src/deps/mod.rs b/installer/src/deps/mod.rs index b9bdd06eb..40ef12b84 100644 --- a/installer/src/deps/mod.rs +++ b/installer/src/deps/mod.rs @@ -10,11 +10,13 @@ use crate::dependency_binaries::{ }; use crate::dirs::{BaseDirs, SystemBaseDirs}; use crate::error::{InstallerError, Result}; -use cap_std::{ambient_authority, fs::Dir}; +use camino::Utf8Path; +use cap_std::{ambient_authority, fs_utf8::Dir}; use std::io; use std::io::Write; use std::path::Path; use std::process::{Command, Output}; +use tracing::trace; mod install; use install::*; @@ -63,6 +65,34 @@ const DYLINT_LINK_TOOL: DependencyTool = DependencyTool { const DEPENDENCY_TOOLS: [DependencyTool; 2] = [CARGO_DYLINT_TOOL, DYLINT_LINK_TOOL]; +/// Stable, bounded failure labels emitted while checking a PATH entry. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PathScanFailureCategory { + NonUtf8Path, + DirectoryOpen, + Metadata, +} + +impl PathScanFailureCategory { + /// Return the low-cardinality field value used by trace events. + const fn as_str(self) -> &'static str { + match self { + Self::NonUtf8Path => "non_utf8_path", + Self::DirectoryOpen => "directory_open", + Self::Metadata => "metadata", + } + } +} + +/// Emit bounded failure context without recording the PATH entry itself. +fn trace_path_scan_failure(binary_name: &str, category: PathScanFailureCategory) { + trace!( + binary_name, + failure_category = category.as_str(), + "skipping PATH scan candidate after lookup failure" + ); +} + /// Status of Dylint tool availability. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DylintToolStatus { @@ -297,17 +327,39 @@ fn find_binary_on_path(binary_name: &str) -> Option { .find_map(|directory| find_binary_in_directory(&directory, binary_name)) } +/// Find the first executable candidate relative to one UTF-8 PATH directory. +/// +/// Non-UTF-8 entries and directories that cannot be opened are skipped so the +/// caller can continue searching later PATH entries. Candidate metadata is +/// inspected through the opened directory capability rather than ambient +/// filesystem access. Lookup failures produce bounded trace fields containing +/// the binary name and failure category, never the directory path. fn find_binary_in_directory(directory: &Path, binary_name: &str) -> Option { - // `cap_std::fs::Dir` (not `fs_utf8`) keeps PATH entries that are not - // valid UTF-8 in the scan instead of silently dropping them. - let directory_capability = Dir::open_ambient_dir(directory, ambient_authority()).ok()?; + let Some(utf8_directory) = Utf8Path::from_path(directory) else { + trace_path_scan_failure(binary_name, PathScanFailureCategory::NonUtf8Path); + return None; + }; + let directory_capability = match Dir::open_ambient_dir(utf8_directory, ambient_authority()) { + Ok(directory_capability) => directory_capability, + Err(_) => { + trace_path_scan_failure(binary_name, PathScanFailureCategory::DirectoryOpen); + return None; + } + }; binary_candidates(binary_name) .into_iter() - .find(|candidate| is_executable_file(&directory_capability, candidate)) + .find(|candidate| { + is_executable_file(&directory_capability, Utf8Path::new(candidate), binary_name) + }) .map(|candidate| directory.join(candidate)) } +/// Return executable candidate names in their PATH-search order. +/// +/// Unix returns the requested name unchanged. Windows expands extensionless +/// names using `PATHEXT`; an existing extension is preserved as the sole +/// candidate. fn binary_candidates(binary_name: &str) -> Vec { #[cfg(windows)] let mut candidates = Vec::new(); @@ -330,6 +382,7 @@ fn binary_candidates(binary_name: &str) -> Vec { candidates } +/// Parse `PATHEXT` into suffixes used to probe Windows PATH entries. #[cfg(windows)] fn windows_path_extensions() -> Vec { let path_ext = std::env::var_os("PATHEXT") @@ -352,25 +405,60 @@ fn windows_path_extensions() -> Vec { .collect() } +/// Check a candidate through its containing directory capability. +/// +/// Metadata failures are treated as non-executable and traced with a bounded +/// failure category; a candidate must also be a regular file with an execute +/// bit set for at least one Unix permission class. The trace records the +/// binary name and `metadata` category without exposing the candidate path. #[cfg(unix)] -fn is_executable_file(directory: &Dir, candidate: &str) -> bool { +fn is_executable_file(directory: &Dir, candidate: &Utf8Path, binary_name: &str) -> bool { use cap_std::fs::PermissionsExt; - directory - .metadata(candidate) - .map(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0) - .unwrap_or(false) + match directory.metadata(candidate) { + Ok(metadata) => metadata.is_file() && metadata.permissions().mode() & 0o111 != 0, + Err(_) => { + trace_path_scan_failure(binary_name, PathScanFailureCategory::Metadata); + false + } + } } +/// Check that a candidate is a regular file through its directory capability. +/// +/// Metadata failures are treated as non-executable and traced with a bounded +/// failure category. The trace records the binary name and `metadata` category +/// without exposing the candidate path. #[cfg(not(unix))] -fn is_executable_file(directory: &Dir, candidate: &str) -> bool { - directory - .metadata(candidate) - .map(|metadata| metadata.is_file()) - .unwrap_or(false) +fn is_executable_file(directory: &Dir, candidate: &Utf8Path, binary_name: &str) -> bool { + match directory.metadata(candidate) { + Ok(metadata) => metadata.is_file(), + Err(_) => { + trace_path_scan_failure(binary_name, PathScanFailureCategory::Metadata); + false + } + } } #[cfg(test)] mod path_tests; #[cfg(test)] mod tests; + +#[cfg(test)] +mod path_scan_tests { + use super::PathScanFailureCategory; + + #[test] + fn path_scan_failure_categories_are_stable_and_bounded() { + assert_eq!( + PathScanFailureCategory::NonUtf8Path.as_str(), + "non_utf8_path" + ); + assert_eq!( + PathScanFailureCategory::DirectoryOpen.as_str(), + "directory_open" + ); + assert_eq!(PathScanFailureCategory::Metadata.as_str(), "metadata"); + } +} diff --git a/installer/src/deps/path_tests.rs b/installer/src/deps/path_tests.rs index 4395b7173..83ad3a78d 100644 --- a/installer/src/deps/path_tests.rs +++ b/installer/src/deps/path_tests.rs @@ -2,8 +2,9 @@ // The cap-std imports are used only by the Unix executable-permission test; // gating them keeps Windows test builds free of unused-import warnings. +use camino::Utf8Path; #[cfg(unix)] -use cap_std::{ambient_authority, fs::Dir}; +use cap_std::{ambient_authority, fs_utf8::Dir}; use temp_env::with_vars_unset; use super::*; @@ -193,11 +194,14 @@ fn is_executable_file_matches_executable_permission( let binary_path = temp_dir.path().join("dylint-link"); write_fake_binary(&binary_path, has_execute_permission)?; - let directory = Dir::open_ambient_dir(temp_dir.path(), ambient_authority()) - .expect("open temp-dir capability"); + let directory = Dir::open_ambient_dir( + Utf8Path::from_path(temp_dir.path()).expect("temp directory path should be UTF-8"), + ambient_authority(), + ) + .expect("open temp-dir capability"); assert_eq!( - is_executable_file(&directory, "dylint-link"), + is_executable_file(&directory, Utf8Path::new("dylint-link"), "dylint-link"), expected_is_executable, ); Ok(()) diff --git a/src/lints/template/validation.rs b/src/lints/template/validation.rs index 66abaf442..481fbd5fc 100644 --- a/src/lints/template/validation.rs +++ b/src/lints/template/validation.rs @@ -97,6 +97,9 @@ pub(crate) fn lint_constant(crate_name: &str) -> String { .collect() } +/// Capitalize one non-empty segment using ASCII case conversion. +/// +/// Returns `None` for an empty segment; non-ASCII characters are preserved. fn capitalize_segment(segment: &str) -> Option { let mut characters = segment.chars(); let first = characters.next()?; @@ -110,6 +113,10 @@ fn capitalize_segment(segment: &str) -> Option { Some(capitalized) } +/// Derive the UI pass struct name by joining capitalized crate-name segments. +/// +/// Hyphens and underscores delimit segments, and empty segments are ignored. +/// ASCII letters are case-normalized while other characters are preserved. pub(crate) fn pass_struct_name(crate_name: &str) -> String { crate_name .split(['-', '_']) From 82d1633f9bf9219faf88aca276abddf55bd81fa0 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 27 Sep 2026 02:31:22 +0200 Subject: [PATCH 10/32] Preserve deliberate spelling contract terms Keep legacy API spellings in compile-fail contracts and SARIF 2.1.0 wire keys exempt from the Oxford spelling gate. Remove the obsolete checkbox exemption and correct the inherited ExecPlan heading. --- docs/execplans/issue-271-ref-pinned-installation.md | 2 +- typos.local.toml | 4 +++- typos.toml | 1 + 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/execplans/issue-271-ref-pinned-installation.md b/docs/execplans/issue-271-ref-pinned-installation.md index f492d4c84..a2ecaeac6 100644 --- a/docs/execplans/issue-271-ref-pinned-installation.md +++ b/docs/execplans/issue-271-ref-pinned-installation.md @@ -477,7 +477,7 @@ detached, `git -C ~/.local/share/whitaker checkout main` restores it by hand. If a gate fails mid-commit, fix and re-run the gate; never commit over a red gate. -## Artifacts and notes +## Artefacts and notes Red/Green evidence (gate logs under `/tmp/*-whitaker-issue-271*.out`): diff --git a/typos.local.toml b/typos.local.toml index 9e49e9e62..c76b1dd49 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -60,7 +60,6 @@ ignore = [ "(?m)^## Tha `expect` toirmisgte taobh a\\-muigh deuchainnean\\.$", "(?m)^#\\. Air a shealltainn ann an breithneachaidhean nuair a tha e aโ€™ toirt iomradh air am buadh roimhe\\.$", "(?m)^\\- Lexical segment matching can mis\\-handle renamed imports, glob imports, or$", - "(?m)^\\- \\*\\*Checkboxes:\\*\\* Precede task and sub\\-task items with a GitHub Flavored$", "(?m)^\\- `\\-\\-artifact\\-server\\-path `: export uploaded artefacts to a host$", "(?m)^bumpy_road_function = Tha iomadh cruinneachadh de loidsig chumhachail neadaichte ann an `\\{ \\$name \\}`\\.$", "(?m)^careful setup before a test can execute and, sometimes, teardown afterward\\.$", @@ -69,6 +68,9 @@ ignore = [ "(?m)^error: Tha gnรฌomh std::fs `std::fs::read` aโ€™ seachnadh aโ€™ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", "(?m)^module_max_lines = Tha mรฒideal \\{ \\$module \\} aโ€™ leudachadh gu \\{ \\$lines \\} loidhnichean agus aโ€™ briseadh an crรฌoch \\{ \\$limit \\}\\.$", "(?m)^no_std_fs_operations = Tha gnรฌomh std::fs `\\{ \\$operation \\}` aโ€™ seachnadh aโ€™ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", + # Legacy public names remain only in compile-fail contracts; SARIF wire keys + # retain the schema's spelling. + "\\b(?:normalise_locale|rasterise_signal|normalise_settings|Artifact|ArtifactLocation|with_artifact|artifact_location|artifactLocation|artifacts)\\b", # Inline code spans are masked; the shared dictionary does not mask them yet. '`[^`\n]+`', ] diff --git a/typos.toml b/typos.toml index 313a143eb..b33fee1c1 100644 --- a/typos.toml +++ b/typos.toml @@ -290,6 +290,7 @@ extend-ignore-re = [ "How to organise your Rust tests", "\\b(?:background|theme)_color\\b", "\\b(?:carousel|dropdown|footer|indicator|items|navbar|toast)-center\\b", + "\\b(?:normalise_locale|rasterise_signal|normalise_settings|Artifact|ArtifactLocation|with_artifact|artifact_location|artifactLocation|artifacts)\\b", "\\bAppFactory Date: Sun, 27 Sep 2026 02:54:31 +0200 Subject: [PATCH 11/32] Document changed lint callbacks (#398) Describe callback filtering, diagnostic fallback behavior, and SARIF result construction so changed production functions have accurate Rustdoc contracts. --- crates/bumpy_road_function/src/driver/mod.rs | 9 +++++++++ .../no_expect_outside_tests/src/diagnostics.rs | 4 ++++ crates/no_expect_outside_tests/src/driver/mod.rs | 5 +++++ crates/no_std_fs_operations/src/driver.rs | 16 ++++++++++++++++ crates/no_unwrap_or_else_panic/src/driver.rs | 4 ++++ crates/whitaker_clones_core/src/run0/emit.rs | 5 +++++ 6 files changed, 43 insertions(+) diff --git a/crates/bumpy_road_function/src/driver/mod.rs b/crates/bumpy_road_function/src/driver/mod.rs index 7171b108a..e5b1851f5 100644 --- a/crates/bumpy_road_function/src/driver/mod.rs +++ b/crates/bumpy_road_function/src/driver/mod.rs @@ -52,6 +52,10 @@ impl Default for BumpyRoadFunction { } impl<'tcx> LateLintPass<'tcx> for BumpyRoadFunction { + /// Load normalized lint settings and the locale-specific message resolver. + /// + /// This runs once before HIR traversal so every analysed body uses the same + /// configuration and localization context. fn check_crate(&mut self, _cx: &LateContext<'tcx>) { self.settings = normalize_settings(load_configuration().into_settings()); let shared_config = SharedConfig::load(); @@ -171,6 +175,11 @@ struct AnalysisTarget { body_id: hir::BodyId, } +/// Build the complexity signal for one body and report separated bumps. +/// +/// Macro-expanded bodies, bodies without source line information, and bodies +/// with fewer than two detected bumps produce no diagnostic. Rasterization or +/// smoothing failures are reported as delayed compiler bugs and stop analysis. fn analyse_body( cx: &LateContext<'_>, target: AnalysisTarget, diff --git a/crates/no_expect_outside_tests/src/diagnostics.rs b/crates/no_expect_outside_tests/src/diagnostics.rs index 8f4535251..da639d8b8 100644 --- a/crates/no_expect_outside_tests/src/diagnostics.rs +++ b/crates/no_expect_outside_tests/src/diagnostics.rs @@ -218,6 +218,10 @@ fn localized_messages( resolve_message_set(lookup, MESSAGE_KEY, &args) } +/// Build the English diagnostic set used when localized resolution fails. +/// +/// The receiver and context labels identify the `expect` call and its enclosing +/// scope; the category selects the matching recovery guidance. fn fallback_messages( receiver: &ReceiverLabel, context: &ContextLabel, diff --git a/crates/no_expect_outside_tests/src/driver/mod.rs b/crates/no_expect_outside_tests/src/driver/mod.rs index 5ecdbfb9a..225d8d8fc 100644 --- a/crates/no_expect_outside_tests/src/driver/mod.rs +++ b/crates/no_expect_outside_tests/src/driver/mod.rs @@ -103,6 +103,11 @@ impl<'tcx> LateLintPass<'tcx> for NoExpectOutsideTests { self.localizer = get_localizer_for_lint("no_expect_outside_tests", shared_config.locale()); } + /// Diagnose `expect` calls on `Option` or `Result` outside test contexts. + /// + /// Doctests, recognized test contexts, and harness-detected test functions + /// are skipped; other method calls and non-`Option`/`Result` receivers are + /// ignored. fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx hir::Expr<'tcx>) { if self.is_doctest { return; diff --git a/crates/no_std_fs_operations/src/driver.rs b/crates/no_std_fs_operations/src/driver.rs index dfeb0ebe0..379e0cb78 100644 --- a/crates/no_std_fs_operations/src/driver.rs +++ b/crates/no_std_fs_operations/src/driver.rs @@ -64,6 +64,10 @@ impl<'tcx> LateLintPass<'tcx> for NoStdFsOperations { } } + /// Classify resolved imports and emit diagnostics for `std::fs` usages. + /// + /// Excluded crates are skipped, as are non-`use` items; each resolved item + /// in a use tree is classified at the import's span. fn check_item(&mut self, cx: &LateContext<'tcx>, item: &'tcx hir::Item<'tcx>) { if self.should_skip() { return; @@ -80,6 +84,11 @@ impl<'tcx> LateLintPass<'tcx> for NoStdFsOperations { } } + /// Classify path, struct-construction, and method-call expressions. + /// + /// Excluded crates and unrelated expression kinds are ignored. Method + /// calls use their resolved definition first, then inspect the receiver + /// type when resolution did not identify a `std::fs` operation. fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx hir::Expr<'tcx>) { if self.should_skip() { return; @@ -113,6 +122,9 @@ impl<'tcx> LateLintPass<'tcx> for NoStdFsOperations { } } + /// Classify path types and emit a diagnostic for a resolved `std::fs` use. + /// + /// Excluded crates and non-path type expressions are ignored. fn check_ty(&mut self, cx: &LateContext<'tcx>, ty: &'tcx hir::Ty<'tcx, AmbigArg>) { if self.should_skip() { return; @@ -179,6 +191,10 @@ impl NoStdFsOperations { emit_diagnostic(cx, span, usage, &self.localizer); } + /// Infer a filesystem operation from a method receiver's standard type. + /// + /// Returns `None` for non-ADT receivers, types outside `std`, or standard + /// types outside `std::fs`; otherwise returns the qualified operation label. fn receiver_usage_for_method( &self, cx: &LateContext<'_>, diff --git a/crates/no_unwrap_or_else_panic/src/driver.rs b/crates/no_unwrap_or_else_panic/src/driver.rs index 39b4e1ed0..eaf1588ea 100644 --- a/crates/no_unwrap_or_else_panic/src/driver.rs +++ b/crates/no_unwrap_or_else_panic/src/driver.rs @@ -77,6 +77,10 @@ impl<'tcx> LateLintPass<'tcx> for NoUnwrapOrElsePanic { self.localizer = get_localizer_for_lint(LINT_NAME, shared_config.locale()); } + /// Diagnose `unwrap_or_else` when an `Option`/`Result` fallback can panic. + /// + /// Calls with another method name or receiver type, a missing/non-closure + /// fallback, or a context accepted by the configured policy are ignored. fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx hir::Expr<'tcx>) { let ExprKind::MethodCall(segment, receiver, args, _) = expr.kind else { return; diff --git a/crates/whitaker_clones_core/src/run0/emit.rs b/crates/whitaker_clones_core/src/run0/emit.rs index b1d747e9f..52bd8d504 100644 --- a/crates/whitaker_clones_core/src/run0/emit.rs +++ b/crates/whitaker_clones_core/src/run0/emit.rs @@ -109,6 +109,11 @@ pub fn emit_run0( Ok(run.build()) } +/// Build one SARIF result for an accepted pair of clone fragments. +/// +/// The primary fragment becomes the result location and the peer becomes a +/// related location. Fingerprint, region, score-conversion, or property-build +/// failures are returned to the caller rather than emitting a partial result. fn build_result( primary: &TokenFragment, peer: &TokenFragment, From 3c2b61d72a00bdd27b64f13bdf6af20c44a33a14 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 27 Sep 2026 03:29:41 +0200 Subject: [PATCH 12/32] Bound compile-contract tests for CI (#401) Give the new API compile-contract tests the existing serialized ten-minute allowance in both nextest profiles, and keep the workflow contract aligned with those complete filters. Scope the `Utf8Path` test import to Unix, where the executable-permission test uses it, so Windows builds avoid an unused-import failure. --- .config/nextest.toml | 4 ++-- installer/src/deps/path_tests.rs | 5 +++-- tests/workflow_contracts/nextest_profile_test.py | 14 +++++++++++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.config/nextest.toml b/.config/nextest.toml index 295a4e315..50b8e73aa 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -50,7 +50,7 @@ test-group = "serial-toolchain-installs" # the Windows lane under the base allowance. A full workspace run also # reached 9m56 in the trybuild harness while compiler admission queued. # Keep that nested build bounded while leaving room to finish under contention. -filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(trybuild_fixtures_compile_without_diagnostics) | (binary(ui) & test(=ui))" +filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(trybuild_fixtures_compile_without_diagnostics) | test(normalize_settings_is_public_and_the_legacy_name_stays_removed) | test(renamed_common_apis_are_public_and_legacy_names_stay_removed) | test(renamed_sarif_api_is_public_and_legacy_names_stay_removed) | (binary(ui) & test(=ui))" slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } test-group = "serial-dylint-ui" @@ -112,7 +112,7 @@ serial-build-script-integration = { max-threads = 1 } # Dylint example compilation for that crate is compiled away on Windows. # The trybuild harness reached 9m56 in a full run while compiler admission # queued, so allow 20 minutes for the complete serial UI group. -filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(example_compiles_without_diagnostics) | test(example_harness_collects_call_site_evidence) | test(trybuild_fixtures_compile_without_diagnostics) | test(ui::example_compiles_under_test_harness) | test(ui::hand_written_test_companion_does_not_exempt_parent_function) | test(ui::rstest_unwrap_outside_tests_still_fails_in_non_harness_code) | test(ui::rstest_empty_companion_does_not_exempt_parent_function) | test(ui::aliased_test_crate_non_companion_does_not_exempt_parent_function) | (binary(ui) & test(=ui))" +filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(example_compiles_without_diagnostics) | test(example_harness_collects_call_site_evidence) | test(trybuild_fixtures_compile_without_diagnostics) | test(normalize_settings_is_public_and_the_legacy_name_stays_removed) | test(renamed_common_apis_are_public_and_legacy_names_stay_removed) | test(renamed_sarif_api_is_public_and_legacy_names_stay_removed) | test(ui::example_compiles_under_test_harness) | test(ui::hand_written_test_companion_does_not_exempt_parent_function) | test(ui::rstest_unwrap_outside_tests_still_fails_in_non_harness_code) | test(ui::rstest_empty_companion_does_not_exempt_parent_function) | test(ui::aliased_test_crate_non_companion_does_not_exempt_parent_function) | (binary(ui) & test(=ui))" test-group = "serial-dylint-ui" retries = { backoff = "exponential", count = 2, delay = "5s" } slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } diff --git a/installer/src/deps/path_tests.rs b/installer/src/deps/path_tests.rs index 83ad3a78d..c5590dfc8 100644 --- a/installer/src/deps/path_tests.rs +++ b/installer/src/deps/path_tests.rs @@ -1,7 +1,8 @@ //! Tests for PATH-based dependency-binary discovery helpers. -// The cap-std imports are used only by the Unix executable-permission test; -// gating them keeps Windows test builds free of unused-import warnings. +// These imports are used only by the Unix executable-permission test; gating +// them keeps Windows test builds free of unused-import warnings. +#[cfg(unix)] use camino::Utf8Path; #[cfg(unix)] use cap_std::{ambient_authority, fs_utf8::Dir}; diff --git a/tests/workflow_contracts/nextest_profile_test.py b/tests/workflow_contracts/nextest_profile_test.py index 50d249fb8..72523238e 100644 --- a/tests/workflow_contracts/nextest_profile_test.py +++ b/tests/workflow_contracts/nextest_profile_test.py @@ -88,7 +88,7 @@ class Allowance(typ.NamedTuple): #: named lose the ten-minute allowance and fall back to the base three #: hundred seconds. The two profiles genuinely differ here, the default one #: naming seven more `test(...)` clauses in its shared UI override than -#: `ci`: twelve against five, with `(binary(ui) & test(=ui))` shared and +#: `ci`: fifteen against eight, with `(binary(ui) & test(=ui))` shared and #: counted in neither. The macro crate's compile contract and parser-pin #: nested Cargo checks have their own identical allowances in both profiles. REQUIRED_OVERRIDES: typ.Final[dict[str, dict[str, Allowance]]] = { @@ -119,6 +119,12 @@ class Allowance(typ.NamedTuple): "test(example_compiles_without_diagnostics) | " "test(example_harness_collects_call_site_evidence) | " "test(trybuild_fixtures_compile_without_diagnostics) | " + "test(normalize_settings_is_public_and_the_legacy_name_stays_" + "removed) | " + "test(renamed_common_apis_are_public_and_legacy_names_stay_" + "removed) | " + "test(renamed_sarif_api_is_public_and_legacy_names_stay_" + "removed) | " "test(ui::example_compiles_under_test_harness) | " "test(ui::hand_written_test_companion_does_not_exempt_parent_" "function) | " @@ -170,6 +176,12 @@ class Allowance(typ.NamedTuple): "test(ui::ui) | " "test(sha2_0_11_pre_migration_patterns_fail_to_compile) | " "test(trybuild_fixtures_compile_without_diagnostics) | " + "test(normalize_settings_is_public_and_the_legacy_name_stays_" + "removed) | " + "test(renamed_common_apis_are_public_and_legacy_names_stay_" + "removed) | " + "test(renamed_sarif_api_is_public_and_legacy_names_stay_" + "removed) | " "(binary(ui) & test(=ui))" ): Allowance( slow_timeout={ From f51112cd01e0e095a0b0b1a4989238ae85bd0fb7 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 27 Sep 2026 05:09:40 +0200 Subject: [PATCH 13/32] Complete spelling review coverage Add SARIF wire-contract and conversion-boundary documentation, classify installer PATH scan failures for bounded tracing, and document the resulting capability and usage contracts. --- .markdownlint-cli2.jsonc | 1 + .../bumpy_road_function/src/driver/numeric.rs | 5 + .../src/driver/numeric.rs | 3 + crates/no_std_fs_operations/src/usage/mod.rs | 15 +- crates/whitaker_sarif/tests/wire_contracts.rs | 4 + docs/developers-guide.md | 26 ++-- .../install/http_test_server.rs | 10 +- installer/src/deps/mod.rs | 139 ++++++++++++++---- installer/src/deps/path_tests.rs | 87 ++++++++++- 9 files changed, 247 insertions(+), 43 deletions(-) diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc index 9b9f99aa1..936350783 100644 --- a/.markdownlint-cli2.jsonc +++ b/.markdownlint-cli2.jsonc @@ -18,6 +18,7 @@ ".vtcode/**", ".terraform/**", ".uv-cache/**", + ".memories/**", "memories/**", "CRUSH.md" ] diff --git a/crates/bumpy_road_function/src/driver/numeric.rs b/crates/bumpy_road_function/src/driver/numeric.rs index 2d548cd36..c971c7e8f 100644 --- a/crates/bumpy_road_function/src/driver/numeric.rs +++ b/crates/bumpy_road_function/src/driver/numeric.rs @@ -18,8 +18,11 @@ pub(super) fn span_byte_offset(offset: u64) -> Option { #[cfg(test)] mod tests { + //! Verify conversion behaviour at diagnostic and compiler integer limits. + use super::{saturating_branch_count, saturating_diagnostic_integer, span_byte_offset}; + /// Verify values beyond Fluent's signed limit clamp without wrapping. #[test] fn diagnostic_integers_saturate_at_the_signed_boundary() { let maximum = i64::MAX.unsigned_abs(); @@ -28,6 +31,7 @@ mod tests { assert_eq!(saturating_diagnostic_integer(maximum + 1), i64::MAX); } + /// Verify branch counts beyond the segment-weight limit clamp to `u32::MAX`. #[test] fn branch_counts_saturate_at_the_unsigned_boundary() { let maximum = u64::from(u32::MAX); @@ -36,6 +40,7 @@ mod tests { assert_eq!(saturating_branch_count(maximum + 1), u32::MAX); } + /// Verify compiler span offsets accept `u32::MAX` and reject larger values. #[test] fn span_offsets_reject_values_above_the_compiler_boundary() { let maximum = u64::from(u32::MAX); diff --git a/crates/conditional_max_n_branches/src/driver/numeric.rs b/crates/conditional_max_n_branches/src/driver/numeric.rs index c4ae68976..3f7d380dc 100644 --- a/crates/conditional_max_n_branches/src/driver/numeric.rs +++ b/crates/conditional_max_n_branches/src/driver/numeric.rs @@ -7,8 +7,11 @@ pub(super) fn saturating_diagnostic_integer(value: u64) -> i64 { #[cfg(test)] mod tests { + //! Verify diagnostic integer conversion at Fluent's signed limit. + use super::saturating_diagnostic_integer; + /// Verify values beyond Fluent's signed limit clamp without wrapping. #[test] fn diagnostic_integers_saturate_at_the_signed_boundary() { let maximum = i64::MAX.unsigned_abs(); diff --git a/crates/no_std_fs_operations/src/usage/mod.rs b/crates/no_std_fs_operations/src/usage/mod.rs index f91c4e580..7d0b1b2c0 100644 --- a/crates/no_std_fs_operations/src/usage/mod.rs +++ b/crates/no_std_fs_operations/src/usage/mod.rs @@ -14,7 +14,9 @@ pub struct StdFsUsage { } impl StdFsUsage { - /// Construct a new usage instance. + /// Store the resolved operation label used by diagnostics. + /// + /// No usage category is retained; diagnostics are based on this label. #[must_use] /// /// # Examples @@ -43,7 +45,10 @@ impl StdFsUsage { } } -/// Classify a resolved path (expression, type, import) into a usage record. +/// Classify a resolved path (expression, type, or import) into a usage record. +/// +/// Return `None` when HIR resolution has no definition or the resolved item is +/// not a `std::fs` path. #[must_use] /// /// # Examples @@ -66,6 +71,9 @@ pub fn classify_qpath( } /// Classify using a `Res` obtained from HIR traversal. +/// +/// Return `None` when the resolution has no `DefId` or does not identify a +/// `std::fs` item. #[must_use] /// /// # Examples @@ -84,6 +92,9 @@ pub fn classify_res(cx: &LateContext<'_>, res: Res) -> Option { } /// Classify a `DefId` by inspecting its fully qualified path. +/// +/// Return `None` when the definition belongs to a crate other than `std` or +/// its fully qualified path is outside `std::fs`. #[must_use] /// /// # Examples diff --git a/crates/whitaker_sarif/tests/wire_contracts.rs b/crates/whitaker_sarif/tests/wire_contracts.rs index 632fffcc0..c0f120819 100644 --- a/crates/whitaker_sarif/tests/wire_contracts.rs +++ b/crates/whitaker_sarif/tests/wire_contracts.rs @@ -109,6 +109,8 @@ fn populated_run_builder_preserves_the_canonical_sarif_property() -> Result<(), } proptest! { + /// Check varied URIs, optional base IDs, and optional regions retain the + /// canonical location key and survive JSON round-trips. #[test] fn generated_physical_locations_keep_the_canonical_wire_key( uri in uri_strategy(), @@ -143,6 +145,8 @@ proptest! { prop_assert_eq!(round_trip, serialized); } + /// Check non-empty artefact lists with optional base IDs and MIME types + /// retain the canonical run key and survive JSON round-trips. #[test] fn generated_runs_keep_the_canonical_wire_key_and_round_trip( artefacts in prop::collection::vec( diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 76cacccfe..5b6e4d4be 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -2496,7 +2496,7 @@ executable file and comparing the version Cargo recorded for it, which needs no execution either. The `dylint-link` verification in `installer/src/deps/mod.rs` is implemented by -five small private helpers: +six small private helpers: - `find_binary_on_path(binary_name)` returns the first executable candidate so the installation check can validate the exact path it found. @@ -2505,27 +2505,35 @@ five small private helpers: candidates relative to that directory capability. Non-UTF-8 entries and directories that cannot be opened are skipped, preserving the search order. - `binary_candidates(binary_name: &str) -> Vec` returns candidate - executable names in PATH-search order; on Windows it expands extensionless - names using `PATHEXT`. + executable names in probe order. Unix returns the requested name unchanged. + Windows preserves a name with an extension as its sole candidate; otherwise, + it appends the `PATHEXT` suffixes in configured order, skipping suffixes + already present at the end of the name, case-insensitively. - `is_executable_file` has this signature: ```rust fn is_executable_file( directory: &Dir, candidate: &Utf8Path, - binary_name: &str, + failures: &mut Vec, ) -> bool ``` - It obtains metadata with `Dir::metadata`, requires a regular file and, on - Unix, at least one executable permission bit. Metadata errors return `false`. - Failure traces include the binary name and a bounded failure category, but - not the PATH entry or candidate path. The executable suffix carries the - meaning on non-Unix targets. + It obtains metadata with `Dir::metadata` and returns `false` if the lookup + fails. A candidate must be a regular file and, on Unix, have at least one + executable permission bit. Lookup failures add the bounded `metadata` + category to the caller-provided list. - `windows_path_extensions()` normalizes `PATHEXT` on Windows so `binary_candidates()` can expand extensionless names the same way the shell does. +`PathScanResult` is a narrowly scoped report for this PATH scan: it carries the +first matching path and the bounded categories encountered while checking +earlier entries. It is not a reusable filesystem abstraction. The scan helpers +return these classifications without logging; `is_dylint_link_installed()` +emits trace events at the installer boundary with the binary name and failure +category, without recording PATH entries or candidate paths. + These key helpers are covered by direct unit tests in `installer/src/deps/path_tests.rs` for missing PATH values, empty PATH values, multiple PATH directories, non-executable Unix files, executable Unix files, diff --git a/installer/src/dependency_binaries/install/http_test_server.rs b/installer/src/dependency_binaries/install/http_test_server.rs index e09c4f6c8..7823189cb 100644 --- a/installer/src/dependency_binaries/install/http_test_server.rs +++ b/installer/src/dependency_binaries/install/http_test_server.rs @@ -115,8 +115,14 @@ fn run_server( } } -/// Read one request, record its path, and write the matching canned response -/// (or a 404). `Connection: close` lets the client frame the response end. +/// Read one request, record its path, and write its canned response or a 404. +/// +/// Bound socket reads and writes with five-second timeouts. Drop the connection +/// without responding if socket setup or cloning fails, or if no request line +/// can be read. A header read failure ends header draining but still uses the +/// parsed request path. `Connection: close` frames the response; write or flush +/// failures, including a client disconnect, are not retried and drop the +/// connection on return. fn serve_connection( mut stream: TcpStream, routes: &HashMap, diff --git a/installer/src/deps/mod.rs b/installer/src/deps/mod.rs index 40ef12b84..05d0af7a8 100644 --- a/installer/src/deps/mod.rs +++ b/installer/src/deps/mod.rs @@ -65,7 +65,7 @@ const DYLINT_LINK_TOOL: DependencyTool = DependencyTool { const DEPENDENCY_TOOLS: [DependencyTool; 2] = [CARGO_DYLINT_TOOL, DYLINT_LINK_TOOL]; -/// Stable, bounded failure labels emitted while checking a PATH entry. +/// Stable, bounded failure labels returned while checking a PATH entry. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PathScanFailureCategory { NonUtf8Path, @@ -84,6 +84,19 @@ impl PathScanFailureCategory { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct PathScanFailure<'binary> { + binary_name: &'binary str, + category: PathScanFailureCategory, +} + +/// Collect the first match and classified failures for one PATH scan. +#[derive(Debug, Default)] +struct PathScanResult<'binary> { + binary: Option, + failures: Vec>, +} + /// Emit bounded failure context without recording the PATH entry itself. fn trace_path_scan_failure(binary_name: &str, category: PathScanFailureCategory) { trace!( @@ -237,6 +250,11 @@ fn is_tool_installed(executor: &dyn CommandExecutor, tool: &DependencyTool) -> b is_versioned_tool_installed(executor, tool, expected_version) } +/// Verify `dylint-link` presence and, when known, its Cargo-recorded version. +/// +/// Presence is established by finding an executable on PATH without running +/// the linker wrapper. An expected version is compared with Cargo's install +/// list; skipped-entry classifications are traced here, without logging paths. fn is_dylint_link_installed( executor: &dyn CommandExecutor, expected_version: Option<&str>, @@ -244,7 +262,11 @@ fn is_dylint_link_installed( // Presence is established by resolving an executable file on PATH. // `dylint-link` is a linker wrapper with no reliable self-reporting // subcommand, so it is never executed to prove it works. - if find_binary_on_path(DYLINT_LINK_TOOL.command).is_none() { + let path_scan = find_binary_on_path(DYLINT_LINK_TOOL.command); + for failure in &path_scan.failures { + trace_path_scan_failure(failure.binary_name, failure.category); + } + if path_scan.binary.is_none() { return false; } let Some(expected_version) = expected_version else { @@ -315,16 +337,44 @@ fn is_binstall_available(executor: &dyn CommandExecutor) -> bool { command_succeeds(executor, "cargo", &["binstall", "--version"]) } +/// Check whether a named executable resolves to a file on the current PATH. +/// +/// This test helper reports presence only; it does not verify an installed +/// package version. #[cfg(test)] fn is_binary_on_path(binary_name: &str) -> bool { - find_binary_on_path(binary_name).is_some() + find_binary_on_path(binary_name).binary.is_some() } -fn find_binary_on_path(binary_name: &str) -> Option { - let path_var = std::env::var_os("PATH")?; +/// Find the first executable match while retaining skipped-entry classifications. +/// +/// Return an empty report when PATH is unset. Otherwise, scan entries in their +/// configured order and return the classifications without emitting logs. +fn find_binary_on_path<'binary>(binary_name: &'binary str) -> PathScanResult<'binary> { + let Some(path_var) = std::env::var_os("PATH") else { + return PathScanResult::default(); + }; + + scan_path_directories(std::env::split_paths(&path_var), binary_name) +} - std::env::split_paths(&path_var) - .find_map(|directory| find_binary_in_directory(&directory, binary_name)) +/// Scan ordered PATH entries and retain classified failures for the caller. +/// +/// The report preserves every skipped-entry category before the first match so +/// the installer boundary can emit bounded diagnostics without coupling the +/// filesystem query helpers to tracing. +fn scan_path_directories<'binary>( + directories: impl IntoIterator, + binary_name: &'binary str, +) -> PathScanResult<'binary> { + let mut result = PathScanResult::default(); + for directory in directories { + result.binary = find_binary_in_directory(&directory, binary_name, &mut result.failures); + if result.binary.is_some() { + break; + } + } + result } /// Find the first executable candidate relative to one UTF-8 PATH directory. @@ -332,27 +382,50 @@ fn find_binary_on_path(binary_name: &str) -> Option { /// Non-UTF-8 entries and directories that cannot be opened are skipped so the /// caller can continue searching later PATH entries. Candidate metadata is /// inspected through the opened directory capability rather than ambient -/// filesystem access. Lookup failures produce bounded trace fields containing -/// the binary name and failure category, never the directory path. -fn find_binary_in_directory(directory: &Path, binary_name: &str) -> Option { +/// filesystem access. Failures are returned as categories without retaining +/// the directory path. +fn find_binary_in_directory<'binary>( + directory: &Path, + binary_name: &'binary str, + failures: &mut Vec>, +) -> Option { let Some(utf8_directory) = Utf8Path::from_path(directory) else { - trace_path_scan_failure(binary_name, PathScanFailureCategory::NonUtf8Path); + failures.push(PathScanFailure { + binary_name, + category: PathScanFailureCategory::NonUtf8Path, + }); return None; }; let directory_capability = match Dir::open_ambient_dir(utf8_directory, ambient_authority()) { Ok(directory_capability) => directory_capability, Err(_) => { - trace_path_scan_failure(binary_name, PathScanFailureCategory::DirectoryOpen); + failures.push(PathScanFailure { + binary_name, + category: PathScanFailureCategory::DirectoryOpen, + }); return None; } }; - binary_candidates(binary_name) + let mut failure_categories = Vec::new(); + let candidate = binary_candidates(binary_name) .into_iter() .find(|candidate| { - is_executable_file(&directory_capability, Utf8Path::new(candidate), binary_name) - }) - .map(|candidate| directory.join(candidate)) + is_executable_file( + &directory_capability, + Utf8Path::new(candidate), + &mut failure_categories, + ) + }); + failures.extend( + failure_categories + .into_iter() + .map(|category| PathScanFailure { + binary_name, + category, + }), + ); + candidate.map(|candidate| directory.join(candidate)) } /// Return executable candidate names in their PATH-search order. @@ -405,36 +478,42 @@ fn windows_path_extensions() -> Vec { .collect() } -/// Check a candidate through its containing directory capability. +/// Classify a candidate through its containing directory capability. /// -/// Metadata failures are treated as non-executable and traced with a bounded -/// failure category; a candidate must also be a regular file with an execute -/// bit set for at least one Unix permission class. The trace records the -/// binary name and `metadata` category without exposing the candidate path. +/// Return whether the candidate is a regular file with at least one Unix +/// executable permission bit. Metadata failures add the bounded `Metadata` +/// category for the caller and return `false`. #[cfg(unix)] -fn is_executable_file(directory: &Dir, candidate: &Utf8Path, binary_name: &str) -> bool { +fn is_executable_file( + directory: &Dir, + candidate: &Utf8Path, + failures: &mut Vec, +) -> bool { use cap_std::fs::PermissionsExt; match directory.metadata(candidate) { Ok(metadata) => metadata.is_file() && metadata.permissions().mode() & 0o111 != 0, Err(_) => { - trace_path_scan_failure(binary_name, PathScanFailureCategory::Metadata); + failures.push(PathScanFailureCategory::Metadata); false } } } -/// Check that a candidate is a regular file through its directory capability. +/// Classify a candidate as a regular file through its directory capability. /// -/// Metadata failures are treated as non-executable and traced with a bounded -/// failure category. The trace records the binary name and `metadata` category -/// without exposing the candidate path. +/// Metadata failures add the bounded `Metadata` category for the caller and +/// return `false`; otherwise, return whether the candidate is a regular file. #[cfg(not(unix))] -fn is_executable_file(directory: &Dir, candidate: &Utf8Path, binary_name: &str) -> bool { +fn is_executable_file( + directory: &Dir, + candidate: &Utf8Path, + failures: &mut Vec, +) -> bool { match directory.metadata(candidate) { Ok(metadata) => metadata.is_file(), Err(_) => { - trace_path_scan_failure(binary_name, PathScanFailureCategory::Metadata); + failures.push(PathScanFailureCategory::Metadata); false } } @@ -447,6 +526,8 @@ mod tests; #[cfg(test)] mod path_scan_tests { + //! Verify that PATH scan failure categories remain stable and bounded. + use super::PathScanFailureCategory; #[test] diff --git a/installer/src/deps/path_tests.rs b/installer/src/deps/path_tests.rs index c5590dfc8..e31e0039c 100644 --- a/installer/src/deps/path_tests.rs +++ b/installer/src/deps/path_tests.rs @@ -183,6 +183,11 @@ fn is_binary_on_path_checks_multiple_directories() -> std::io::Result<()> { Ok(()) } +/// Verify executable permission bits determine Unix candidate classification. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary candidate cannot be created. #[cfg(unix)] #[rstest::rstest] #[case::non_executable(false, false)] @@ -202,12 +207,92 @@ fn is_executable_file_matches_executable_permission( .expect("open temp-dir capability"); assert_eq!( - is_executable_file(&directory, Utf8Path::new("dylint-link"), "dylint-link"), + is_executable_file(&directory, Utf8Path::new("dylint-link"), &mut Vec::new(),), expected_is_executable, ); Ok(()) } +/// Verify skipped invalid entries are classified and do not stop later searches. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixtures cannot be created. +#[cfg(unix)] +#[test] +fn path_scan_classifies_invalid_entries_and_continues_to_a_later_binary() -> std::io::Result<()> { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let temp_dir = tempfile::tempdir()?; + let mut invalid_path_bytes = temp_dir.path().as_os_str().as_bytes().to_vec(); + invalid_path_bytes.push(0xff); + let non_utf8_entry = std::path::PathBuf::from(std::ffi::OsString::from_vec(invalid_path_bytes)); + + let not_a_directory = temp_dir.path().join("not-a-directory"); + std::fs::write(¬_a_directory, b"not a directory")?; + + let valid_directory = tempfile::tempdir()?; + let expected_binary = valid_directory.path().join("dylint-link"); + write_fake_binary(&expected_binary, true)?; + + let scan = scan_path_directories( + vec![ + non_utf8_entry, + not_a_directory, + valid_directory.path().to_path_buf(), + ], + "dylint-link", + ); + + assert_eq!(scan.binary, Some(expected_binary)); + assert_eq!( + scan.failures, + vec![ + PathScanFailure { + binary_name: "dylint-link", + category: PathScanFailureCategory::NonUtf8Path, + }, + PathScanFailure { + binary_name: "dylint-link", + category: PathScanFailureCategory::DirectoryOpen, + }, + ] + ); + Ok(()) +} + +/// Verify a missing candidate is classified and later PATH entries are searched. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixtures cannot be created. +#[test] +fn path_scan_classifies_missing_metadata_and_continues_to_a_later_binary() -> std::io::Result<()> { + let empty_directory = tempfile::tempdir()?; + let valid_directory = tempfile::tempdir()?; + let binary_name = "dylint-link.exe"; + let expected_binary = valid_directory.path().join(binary_name); + write_fake_binary(&expected_binary, true)?; + + let scan = scan_path_directories( + vec![ + empty_directory.path().to_path_buf(), + valid_directory.path().to_path_buf(), + ], + binary_name, + ); + + assert_eq!(scan.binary, Some(expected_binary)); + assert_eq!( + scan.failures, + vec![PathScanFailure { + binary_name, + category: PathScanFailureCategory::Metadata, + }] + ); + Ok(()) +} + #[cfg(windows)] #[rstest::rstest] #[case("dylint-link.exe", 0, true)] From 16e118bd6d438489419a1b6457c76181cf95c312 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 27 Sep 2026 06:13:23 +0200 Subject: [PATCH 14/32] Improve installer PATH diagnostics Treat absent PATH candidates as routine, while preserving classification of other metadata failures. Configure verbose CLI output for bounded debug events and test first-match ordering across generated layouts. Document the lookup contract and review-scope Rustdoc omissions. --- Cargo.lock | 61 ++++++++++++ Cargo.toml | 1 + crates/no_std_fs_operations/src/usage/mod.rs | 8 ++ crates/whitaker_sarif/tests/wire_contracts.rs | 2 + docs/developers-guide.md | 37 +++++--- installer/Cargo.toml | 2 + installer/src/deps/mod.rs | 65 ++++++++++--- installer/src/deps/path_tests.rs | 93 +++++++++++++++++-- installer/src/main.rs | 21 +++++ 9 files changed, 256 insertions(+), 34 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ad798b4b8..c6604a2c1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1843,6 +1843,15 @@ dependencies = [ "whitaker_test_macros", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2804,6 +2813,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3079,6 +3097,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3201,6 +3228,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -3422,6 +3475,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "value-bag" version = "1.13.0" @@ -3593,6 +3652,7 @@ dependencies = [ "log", "mockall", "parking_lot", + "proptest", "rstest", "rstest-bdd", "rstest-bdd-macros", @@ -3605,6 +3665,7 @@ dependencies = [ "thiserror 2.0.21", "toml 1.1.7+spec-1.1.0", "tracing", + "tracing-subscriber", "trybuild", "ureq", "wait-timeout", diff --git a/Cargo.toml b/Cargo.toml index 184e118be..fb4009ae9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -39,6 +39,7 @@ toml = "1.1.2" metrics = "0.24.2" metrics-util = "0.20.1" tracing = { version = "0.1.44", default-features = false, features = ["attributes", "std"] } +tracing-subscriber = "0.3.20" thiserror = "2" trybuild = "1.0.116" log = "0.4.28" diff --git a/crates/no_std_fs_operations/src/usage/mod.rs b/crates/no_std_fs_operations/src/usage/mod.rs index 7d0b1b2c0..51bf45488 100644 --- a/crates/no_std_fs_operations/src/usage/mod.rs +++ b/crates/no_std_fs_operations/src/usage/mod.rs @@ -117,6 +117,10 @@ pub fn classify_def_id(cx: &LateContext<'_>, def_id: DefId) -> Option bool { let segments = path.segments(); segments.len() >= 2 && segments[0] == "std" && segments[1] == "fs" @@ -127,6 +131,10 @@ fn is_invalid_label_char(ch: char) -> bool { ch.is_whitespace() || matches!(ch, '(' | ')') } +/// Validate a resolved definition label as `std::fs` or one of its children. +/// +/// Leading/trailing whitespace, empty labels, whitespace or parentheses +/// within the label, and partial-prefix matches are rejected. pub(crate) fn label_is_std_fs(label: &str) -> bool { if label != label.trim() { return false; diff --git a/crates/whitaker_sarif/tests/wire_contracts.rs b/crates/whitaker_sarif/tests/wire_contracts.rs index c0f120819..700d613a0 100644 --- a/crates/whitaker_sarif/tests/wire_contracts.rs +++ b/crates/whitaker_sarif/tests/wire_contracts.rs @@ -25,6 +25,7 @@ fn assert_run_wire_keys(json: &Value) { assert!(json.get("artefacts").is_none()); } +/// Verify populated locations use the SARIF key and decode canonical JSON. #[test] fn populated_physical_location_uses_the_canonical_sarif_property() -> Result<(), serde_json::Error> { @@ -68,6 +69,7 @@ fn populated_physical_location_uses_the_canonical_sarif_property() -> Result<(), Ok(()) } +/// Verify the public run builder and model retain the canonical `artifacts` key. #[test] fn populated_run_builder_preserves_the_canonical_sarif_property() -> Result<(), serde_json::Error> { let artefact = Artefact { diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 5b6e4d4be..a8638f719 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -2500,10 +2500,22 @@ six small private helpers: - `find_binary_on_path(binary_name)` returns the first executable candidate so the installation check can validate the exact path it found. -- `find_binary_in_directory(directory, binary_name)` converts each PATH entry - to `camino::Utf8Path`, opens it as a `cap_std::fs_utf8::Dir`, and checks - candidates relative to that directory capability. Non-UTF-8 entries and - directories that cannot be opened are skipped, preserving the search order. +- `find_binary_in_directory` checks candidates relative to one PATH entry and + returns the first match joined to the original directory. Its inputs make the + path, binary name, and failure accumulator explicit: + + ```rust + fn find_binary_in_directory<'binary>( + directory: &Path, + binary_name: &'binary str, + failures: &mut Vec>, + ) -> Option + ``` + + It converts the entry to `camino::Utf8Path`, opens a `cap_std::fs_utf8::Dir`, + and checks candidate names relative to that capability. Non-UTF-8 entries and + directories that cannot be opened are skipped; classified failures are + appended without retaining the path. - `binary_candidates(binary_name: &str) -> Vec` returns candidate executable names in probe order. Unix returns the requested name unchanged. Windows preserves a name with an extension as its sole candidate; otherwise, @@ -2519,20 +2531,21 @@ six small private helpers: ) -> bool ``` - It obtains metadata with `Dir::metadata` and returns `false` if the lookup - fails. A candidate must be a regular file and, on Unix, have at least one - executable permission bit. Lookup failures add the bounded `metadata` - category to the caller-provided list. + It obtains metadata with `Dir::metadata`, requires a regular file and, on + Unix, at least one executable permission bit. Metadata errors always return + `false`; ordinary `NotFound` misses add no failure category, while other + metadata errors add the bounded `metadata` category to the caller-provided + list. - `windows_path_extensions()` normalizes `PATHEXT` on Windows so `binary_candidates()` can expand extensionless names the same way the shell does. `PathScanResult` is a narrowly scoped report for this PATH scan: it carries the first matching path and the bounded categories encountered while checking -earlier entries. It is not a reusable filesystem abstraction. The scan helpers -return these classifications without logging; `is_dylint_link_installed()` -emits trace events at the installer boundary with the binary name and failure -category, without recording PATH entries or candidate paths. +entries. It is not a reusable filesystem abstraction. The scan helpers return +these classifications without logging. At verbose level, the installer CLI +emits structured debug events with `binary_name` and `failure_category`; it +does not record PATH values or candidate paths. These key helpers are covered by direct unit tests in `installer/src/deps/path_tests.rs` for missing PATH values, empty PATH values, diff --git a/installer/Cargo.toml b/installer/Cargo.toml index 01226879c..7bf897afe 100644 --- a/installer/Cargo.toml +++ b/installer/Cargo.toml @@ -74,6 +74,7 @@ tempfile = { workspace = true } thiserror = { workspace = true } toml = { workspace = true } tracing = { workspace = true } +tracing-subscriber = { workspace = true } ureq = { workspace = true } wait-timeout = { workspace = true } zip = { workspace = true } @@ -82,6 +83,7 @@ zstd = { workspace = true } [dev-dependencies] whitaker_test_macros = { workspace = true } libc = { workspace = true } +proptest = { workspace = true } mockall = { workspace = true } rstest = { workspace = true } rstest-bdd = { workspace = true } diff --git a/installer/src/deps/mod.rs b/installer/src/deps/mod.rs index 05d0af7a8..69c692a47 100644 --- a/installer/src/deps/mod.rs +++ b/installer/src/deps/mod.rs @@ -16,7 +16,7 @@ use std::io; use std::io::Write; use std::path::Path; use std::process::{Command, Output}; -use tracing::trace; +use tracing::debug; mod install; use install::*; @@ -97,9 +97,14 @@ struct PathScanResult<'binary> { failures: Vec>, } +/// Classify metadata errors, omitting the ordinary miss for an absent candidate. +fn metadata_failure_category(kind: io::ErrorKind) -> Option { + (kind != io::ErrorKind::NotFound).then_some(PathScanFailureCategory::Metadata) +} + /// Emit bounded failure context without recording the PATH entry itself. -fn trace_path_scan_failure(binary_name: &str, category: PathScanFailureCategory) { - trace!( +fn debug_path_scan_failure(binary_name: &str, category: PathScanFailureCategory) { + debug!( binary_name, failure_category = category.as_str(), "skipping PATH scan candidate after lookup failure" @@ -235,6 +240,11 @@ pub fn install_dylint_tools_with_options( /// Arguments used to query Cargo's registry of installed binaries. const CARGO_INSTALL_LIST_ARGS: [&str; 2] = ["install", "--list"]; +/// Check whether a dependency tool is installed at its manifest version. +/// +/// Dylint-link is checked through PATH and Cargo's install list because the +/// wrapper cannot reliably report its own version. If embedded version lookup +/// fails, other tools fall back to a success-only command probe. fn is_tool_installed(executor: &dyn CommandExecutor, tool: &DependencyTool) -> bool { // The manifest is embedded in the binary, so a lookup failure is // effectively unreachable. Should it ever occur, degrade to the previous @@ -264,7 +274,7 @@ fn is_dylint_link_installed( // subcommand, so it is never executed to prove it works. let path_scan = find_binary_on_path(DYLINT_LINK_TOOL.command); for failure in &path_scan.failures { - trace_path_scan_failure(failure.binary_name, failure.category); + debug_path_scan_failure(failure.binary_name, failure.category); } if path_scan.binary.is_none() { return false; @@ -281,6 +291,11 @@ fn is_dylint_link_installed( .is_some_and(|version| version == expected_version) } +/// Probe a tool and, when available, compare its reported version. +/// +/// Without an expected version, command success alone means installed. With +/// one, the command must succeed and stdout's first `MAJOR.MINOR.PATCH`-shaped +/// token must exactly match it; command errors or mismatches return `false`. fn is_versioned_tool_installed( executor: &dyn CommandExecutor, tool: &DependencyTool, @@ -333,6 +348,9 @@ fn first_semver_token(text: &str) -> Option<&str> { }) } +/// Return whether `cargo binstall --version` exits successfully. +/// +/// This checks command availability only; it does not compare a version. fn is_binstall_available(executor: &dyn CommandExecutor) -> bool { command_succeeds(executor, "cargo", &["binstall", "--version"]) } @@ -480,9 +498,10 @@ fn windows_path_extensions() -> Vec { /// Classify a candidate through its containing directory capability. /// -/// Return whether the candidate is a regular file with at least one Unix -/// executable permission bit. Metadata failures add the bounded `Metadata` -/// category for the caller and return `false`. +/// Return whether the candidate is a regular file with an executable bit. +/// +/// Missing candidates return `false` without a failure category; other +/// metadata errors add the bounded `Metadata` category and also return `false`. #[cfg(unix)] fn is_executable_file( directory: &Dir, @@ -493,8 +512,10 @@ fn is_executable_file( match directory.metadata(candidate) { Ok(metadata) => metadata.is_file() && metadata.permissions().mode() & 0o111 != 0, - Err(_) => { - failures.push(PathScanFailureCategory::Metadata); + Err(error) => { + if let Some(category) = metadata_failure_category(error.kind()) { + failures.push(category); + } false } } @@ -502,8 +523,9 @@ fn is_executable_file( /// Classify a candidate as a regular file through its directory capability. /// -/// Metadata failures add the bounded `Metadata` category for the caller and -/// return `false`; otherwise, return whether the candidate is a regular file. +/// Missing candidates return `false` without a failure category; other +/// metadata errors add the bounded `Metadata` category. Otherwise, return +/// whether the candidate is a regular file. #[cfg(not(unix))] fn is_executable_file( directory: &Dir, @@ -512,8 +534,10 @@ fn is_executable_file( ) -> bool { match directory.metadata(candidate) { Ok(metadata) => metadata.is_file(), - Err(_) => { - failures.push(PathScanFailureCategory::Metadata); + Err(error) => { + if let Some(category) = metadata_failure_category(error.kind()) { + failures.push(category); + } false } } @@ -528,7 +552,20 @@ mod tests; mod path_scan_tests { //! Verify that PATH scan failure categories remain stable and bounded. - use super::PathScanFailureCategory; + use super::{PathScanFailureCategory, metadata_failure_category}; + + /// Treat an absent PATH candidate as routine while preserving other failures. + #[test] + fn metadata_failures_exclude_missing_candidates() { + assert_eq!( + metadata_failure_category(std::io::ErrorKind::NotFound), + None + ); + assert_eq!( + metadata_failure_category(std::io::ErrorKind::PermissionDenied), + Some(PathScanFailureCategory::Metadata) + ); + } #[test] fn path_scan_failure_categories_are_stable_and_bounded() { diff --git a/installer/src/deps/path_tests.rs b/installer/src/deps/path_tests.rs index e31e0039c..1ca624e5a 100644 --- a/installer/src/deps/path_tests.rs +++ b/installer/src/deps/path_tests.rs @@ -261,13 +261,13 @@ fn path_scan_classifies_invalid_entries_and_continues_to_a_later_binary() -> std Ok(()) } -/// Verify a missing candidate is classified and later PATH entries are searched. +/// Verify missing candidates are quiet and later PATH entries are searched. /// /// # Errors /// /// Returns an I/O error if the temporary PATH fixtures cannot be created. #[test] -fn path_scan_classifies_missing_metadata_and_continues_to_a_later_binary() -> std::io::Result<()> { +fn path_scan_skips_missing_candidates_and_continues_to_a_later_binary() -> std::io::Result<()> { let empty_directory = tempfile::tempdir()?; let valid_directory = tempfile::tempdir()?; let binary_name = "dylint-link.exe"; @@ -283,16 +283,93 @@ fn path_scan_classifies_missing_metadata_and_continues_to_a_later_binary() -> st ); assert_eq!(scan.binary, Some(expected_binary)); - assert_eq!( - scan.failures, - vec![PathScanFailure { - binary_name, - category: PathScanFailureCategory::Metadata, - }] + assert!(scan.failures.is_empty()); + Ok(()) +} + +/// Verify a PATH scan selects its first executable and ignores later entries. +#[test] +fn path_scan_returns_the_first_executable_match() -> std::io::Result<()> { + let first_directory = tempfile::tempdir()?; + let later_directory = tempfile::tempdir()?; + let binary_name = "probe.exe"; + let expected_binary = first_directory.path().join(binary_name); + write_fake_binary(&expected_binary, true)?; + write_fake_binary(&later_directory.path().join(binary_name), true)?; + + let scan = scan_path_directories( + vec![ + first_directory.path().to_path_buf(), + later_directory.path().to_path_buf(), + ], + binary_name, ); + + assert_eq!(scan.binary, Some(expected_binary)); + assert!(scan.failures.is_empty()); Ok(()) } +/// Check first-match and stop-after-match behaviour for bounded PATH patterns. +#[test] +fn path_scan_ordering_holds_for_bounded_match_patterns() { + use proptest::prelude::*; + + let mut runner = proptest::test_runner::TestRunner::new(proptest::test_runner::Config { + failure_persistence: None, + rng_seed: proptest::test_runner::RngSeed::Fixed(0x51_7a_00), + ..proptest::test_runner::Config::default() + }); + runner + .run(&prop::collection::vec(0_u8..3, 1..=5), |entry_kinds| { + let mut roots = Vec::new(); + let directories = entry_kinds + .iter() + .map(|entry_kind| { + let directory = tempfile::tempdir().expect("create PATH fixture directory"); + let entry = if *entry_kind == 0 { + directory.path().to_path_buf() + } else if *entry_kind == 1 { + let file = directory.path().join("not-a-directory"); + std::fs::write(&file, b"not a directory") + .expect("create non-directory PATH entry"); + file + } else { + write_fake_binary(&directory.path().join("probe.exe"), true) + .expect("create executable PATH fixture"); + directory.path().to_path_buf() + }; + roots.push(directory); + entry + }) + .collect::>(); + let expected_binary = + directories + .iter() + .zip(&entry_kinds) + .find_map(|(directory, entry_kind)| { + (*entry_kind == 2).then(|| directory.join("probe.exe")) + }); + let expected_failures = entry_kinds + .iter() + .take_while(|entry_kind| **entry_kind != 2) + .filter(|entry_kind| **entry_kind == 1) + .map(|_| PathScanFailure { + binary_name: "probe.exe", + category: PathScanFailureCategory::DirectoryOpen, + }) + .collect::>(); + + let scan = scan_path_directories(directories, "probe.exe"); + + prop_assert_eq!(scan.binary, expected_binary); + prop_assert_eq!(scan.failures, expected_failures); + drop(roots); + Ok(()) + }) + .expect("ordered PATH scan invariant should hold for generated layouts"); +} + #[cfg(windows)] #[rstest::rstest] #[case("dylint-link.exe", 0, true)] diff --git a/installer/src/main.rs b/installer/src/main.rs index 76fa4fd36..574a3b9bd 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -17,6 +17,7 @@ use camino::{Utf8Path, Utf8PathBuf}; use clap::Parser; use std::io::Write; use std::time::Instant; +use tracing::Level; use whitaker_installer::cli::{Cli, Command, InstallArgs}; use whitaker_installer::crate_name::CrateName; use whitaker_installer::deps::{SourcePolicy, SystemCommandExecutor}; @@ -34,6 +35,7 @@ use whitaker_installer::wrapper::{generate_wrapper_scripts, path_instructions}; fn main() { let cli = Cli::parse(); + configure_tracing(cli.install_args()); let mut stdout = std::io::stdout(); let mut stderr = std::io::stderr(); let run_result = run(&cli, &mut stdout, &mut stderr); @@ -43,6 +45,25 @@ fn main() { } } +/// Enable bounded stderr diagnostics according to install verbosity flags. +/// +/// Quiet mode leaves only error-level events enabled; default mode enables +/// warnings, while `-v` also exposes debug diagnostics. Formatting omits ANSI +/// escapes for predictable redirected output. +fn configure_tracing(args: &InstallArgs) { + let max_level = if args.quiet { + Level::ERROR + } else if args.verbosity > 0 { + Level::DEBUG + } else { + Level::WARN + }; + let _ = tracing_subscriber::fmt() + .with_ansi(false) + .with_max_level(max_level) + .try_init(); +} + /// Routes CLI commands to their respective handlers. fn run(cli: &Cli, stdout: &mut dyn Write, stderr: &mut dyn Write) -> Result<()> { match &cli.command { From 87f383978715ee640dff55d80e392ef6c387c07b Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 27 Sep 2026 06:40:14 +0200 Subject: [PATCH 15/32] Consolidate PATH scan tests Combine the missing-candidate and first-match examples to avoid duplicated fixture setup while retaining explicit coverage for both behaviours. Keep the generated ordering property for broader coverage. --- installer/src/deps/path_tests.rs | 36 +++++++------------------------- 1 file changed, 8 insertions(+), 28 deletions(-) diff --git a/installer/src/deps/path_tests.rs b/installer/src/deps/path_tests.rs index 1ca624e5a..e99d5d213 100644 --- a/installer/src/deps/path_tests.rs +++ b/installer/src/deps/path_tests.rs @@ -261,46 +261,26 @@ fn path_scan_classifies_invalid_entries_and_continues_to_a_later_binary() -> std Ok(()) } -/// Verify missing candidates are quiet and later PATH entries are searched. +/// Verify missing candidates are quiet and the first executable wins. /// /// # Errors /// /// Returns an I/O error if the temporary PATH fixtures cannot be created. #[test] -fn path_scan_skips_missing_candidates_and_continues_to_a_later_binary() -> std::io::Result<()> { +fn path_scan_skips_missing_candidates_and_returns_the_first_binary() -> std::io::Result<()> { let empty_directory = tempfile::tempdir()?; - let valid_directory = tempfile::tempdir()?; + let first_binary_directory = tempfile::tempdir()?; + let later_binary_directory = tempfile::tempdir()?; let binary_name = "dylint-link.exe"; - let expected_binary = valid_directory.path().join(binary_name); + let expected_binary = first_binary_directory.path().join(binary_name); write_fake_binary(&expected_binary, true)?; + write_fake_binary(&later_binary_directory.path().join(binary_name), true)?; let scan = scan_path_directories( vec![ empty_directory.path().to_path_buf(), - valid_directory.path().to_path_buf(), - ], - binary_name, - ); - - assert_eq!(scan.binary, Some(expected_binary)); - assert!(scan.failures.is_empty()); - Ok(()) -} - -/// Verify a PATH scan selects its first executable and ignores later entries. -#[test] -fn path_scan_returns_the_first_executable_match() -> std::io::Result<()> { - let first_directory = tempfile::tempdir()?; - let later_directory = tempfile::tempdir()?; - let binary_name = "probe.exe"; - let expected_binary = first_directory.path().join(binary_name); - write_fake_binary(&expected_binary, true)?; - write_fake_binary(&later_directory.path().join(binary_name), true)?; - - let scan = scan_path_directories( - vec![ - first_directory.path().to_path_buf(), - later_directory.path().to_path_buf(), + first_binary_directory.path().to_path_buf(), + later_binary_directory.path().to_path_buf(), ], binary_name, ); From 7a52fe2e5b116b89b826d81e81df090771727781 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 14 Oct 2026 20:16:26 +0200 Subject: [PATCH 16/32] Refine spelling, tracing, and test helpers Replace the broad SARIF wire-key exemption with precise spelling cases. Add isolated tests for tracing output and document its stderr contract. Simplify the HTTP fixture connection checks to clear the CodeScene findings. --- Cargo.lock | 61 ----- .../function_attrs_follow_docs/src/driver.rs | 1 + .../no_expect_outside_tests/src/behaviour.rs | 1 + .../src/diagnostics.rs | 1 + .../tests/features/context_summary.feature | 2 +- crates/no_std_fs_operations/src/behaviour.rs | 1 + .../no_std_fs_operations/src/diagnostics.rs | 1 + docs/developers-guide.md | 7 + .../dependency_binaries/install/checksum.rs | 1 + .../install/http_test_server.rs | 17 +- installer/src/main.rs | 24 +- installer/src/tracing_config.rs | 128 +++++++++++ typos.local.toml | 10 +- typos.toml | 210 +----------------- 14 files changed, 168 insertions(+), 297 deletions(-) create mode 100644 installer/src/tracing_config.rs diff --git a/Cargo.lock b/Cargo.lock index c6604a2c1..ad798b4b8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1843,15 +1843,6 @@ dependencies = [ "whitaker_test_macros", ] -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -2813,15 +2804,6 @@ dependencies = [ "digest", ] -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - [[package]] name = "shlex" version = "2.0.1" @@ -3097,15 +3079,6 @@ dependencies = [ "syn 3.0.6", ] -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -3228,32 +3201,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "nu-ansi-term", - "sharded-slab", - "smallvec", - "thread_local", - "tracing-core", - "tracing-log", ] [[package]] @@ -3475,12 +3422,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - [[package]] name = "value-bag" version = "1.13.0" @@ -3652,7 +3593,6 @@ dependencies = [ "log", "mockall", "parking_lot", - "proptest", "rstest", "rstest-bdd", "rstest-bdd-macros", @@ -3665,7 +3605,6 @@ dependencies = [ "thiserror 2.0.21", "toml 1.1.7+spec-1.1.0", "tracing", - "tracing-subscriber", "trybuild", "ureq", "wait-timeout", diff --git a/crates/function_attrs_follow_docs/src/driver.rs b/crates/function_attrs_follow_docs/src/driver.rs index 9f5ef801a..bd2b81722 100644 --- a/crates/function_attrs_follow_docs/src/driver.rs +++ b/crates/function_attrs_follow_docs/src/driver.rs @@ -331,6 +331,7 @@ const MESSAGE_KEY: MessageKey<'static> = MessageKey::new("function_attrs_follow_ type FunctionAttrsMessages = DiagnosticMessageSet; +/// Build localized diagnostics for an attribute kind using the supplied lookup. #[cfg(test)] fn localized_messages( lookup: &impl BundleLookup, diff --git a/crates/no_expect_outside_tests/src/behaviour.rs b/crates/no_expect_outside_tests/src/behaviour.rs index 1f903e09a..4548e4cdf 100644 --- a/crates/no_expect_outside_tests/src/behaviour.rs +++ b/crates/no_expect_outside_tests/src/behaviour.rs @@ -53,6 +53,7 @@ impl ContextWorld { *self.is_doctest.borrow_mut() = true; } + /// Classify accumulated context and record whether linting should be skipped. fn evaluate(&self) { let entries = self.entries.borrow(); let summary = summarize_context( diff --git a/crates/no_expect_outside_tests/src/diagnostics.rs b/crates/no_expect_outside_tests/src/diagnostics.rs index da639d8b8..0e285c970 100644 --- a/crates/no_expect_outside_tests/src/diagnostics.rs +++ b/crates/no_expect_outside_tests/src/diagnostics.rs @@ -194,6 +194,7 @@ const MESSAGE_KEY: MessageKey<'static> = MessageKey::new("no_expect_outside_test type NoExpectMessages = DiagnosticMessageSet; +/// Build localized diagnostics for the supplied receiver and context. #[cfg(test)] fn localized_messages( lookup: &impl BundleLookup, diff --git a/crates/no_expect_outside_tests/tests/features/context_summary.feature b/crates/no_expect_outside_tests/tests/features/context_summary.feature index 3a22aaa2f..14469084e 100644 --- a/crates/no_expect_outside_tests/tests/features/context_summary.feature +++ b/crates/no_expect_outside_tests/tests/features/context_summary.feature @@ -1,4 +1,4 @@ -Feature: Summarise traversal context for `.expect(..)` linting +Feature: Summarize traversal context for `.expect(..)` linting Scenario: Plain function without test attributes Given a non-test function named handler diff --git a/crates/no_std_fs_operations/src/behaviour.rs b/crates/no_std_fs_operations/src/behaviour.rs index d3b848271..98b772e85 100644 --- a/crates/no_std_fs_operations/src/behaviour.rs +++ b/crates/no_std_fs_operations/src/behaviour.rs @@ -28,6 +28,7 @@ impl LocalizationWorld { self.failing = true; } + /// Resolve localized messages with the selected locale or configured failure. fn resolve(&mut self) { let op = self.operation.clone(); let result = if self.failing { diff --git a/crates/no_std_fs_operations/src/diagnostics.rs b/crates/no_std_fs_operations/src/diagnostics.rs index 77c77cb31..ebb6c981a 100644 --- a/crates/no_std_fs_operations/src/diagnostics.rs +++ b/crates/no_std_fs_operations/src/diagnostics.rs @@ -74,6 +74,7 @@ fn sanitize_message(text: String) -> String { .collect() } +/// Build localized diagnostics for the supplied filesystem operation. #[cfg(test)] pub(crate) fn localized_messages( lookup: &impl BundleLookup, diff --git a/docs/developers-guide.md b/docs/developers-guide.md index a8638f719..ad0c8068b 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -3366,6 +3366,13 @@ Whitaker data directory keyed by toolchain and target: - Windows: `%LOCALAPPDATA%\whitaker\lints\\\lib` +The private `installer/src/tracing_config.rs` formatter belongs only to CLI +startup and its colocated tests; other modules must emit events rather than +reuse or initialize this subscriber. It sends diagnostics to stderr, enables +errors in quiet mode, warnings by default, and debug events when verbosity is +positive, with ANSI escapes disabled. Tests capture formatted events with a +thread-scoped subscriber and verify stderr routing in an isolated subprocess. + ### Configuration options - `-t, --target-dir DIR` โ€” Staging directory for built libraries diff --git a/installer/src/dependency_binaries/install/checksum.rs b/installer/src/dependency_binaries/install/checksum.rs index ed7ab3347..414a606af 100644 --- a/installer/src/dependency_binaries/install/checksum.rs +++ b/installer/src/dependency_binaries/install/checksum.rs @@ -253,6 +253,7 @@ mod tests { } impl Read for ChunkedReader<'_> { + /// Read at most the configured chunk size and record each call. fn read(&mut self, buf: &mut [u8]) -> io::Result { self.reads += 1; if self.pending_interrupts > 0 { diff --git a/installer/src/dependency_binaries/install/http_test_server.rs b/installer/src/dependency_binaries/install/http_test_server.rs index 7823189cb..6dd601de7 100644 --- a/installer/src/dependency_binaries/install/http_test_server.rs +++ b/installer/src/dependency_binaries/install/http_test_server.rs @@ -52,6 +52,7 @@ pub(super) struct LocalServer { } impl LocalServer { + /// Start a loopback HTTP server for the supplied canned routes. pub(super) fn start(routes: HashMap) -> Self { let listener = TcpListener::bind("127.0.0.1:0").expect("bind loopback listener"); let port = listener.local_addr().expect("resolve local addr").port(); @@ -85,6 +86,7 @@ impl LocalServer { } impl Drop for LocalServer { + /// Stop the server loop and wait for its thread to exit. fn drop(&mut self) { self.stop.store(true, Ordering::Relaxed); if let Some(handle) = self.handle.take() @@ -132,14 +134,11 @@ fn serve_connection( // (the listener is non-blocking only so the accept loop can poll for // shutdown). `try_clone` shares the socket, so `peer` inherits these. // Drop the connection if the socket cannot be configured. - if stream.set_nonblocking(false).is_err() - || stream - .set_read_timeout(Some(Duration::from_secs(5))) - .is_err() - || stream - .set_write_timeout(Some(Duration::from_secs(5))) - .is_err() - { + let setup_result = stream + .set_nonblocking(false) + .and_then(|()| stream.set_read_timeout(Some(Duration::from_secs(5)))) + .and_then(|()| stream.set_write_timeout(Some(Duration::from_secs(5)))); + if setup_result.is_err() { return; } let Ok(peer) = stream.try_clone() else { @@ -159,7 +158,7 @@ fn serve_connection( loop { let mut line = String::new(); let bytes_read = reader.read_line(&mut line).unwrap_or(0); - if bytes_read == 0 || line == "\r\n" || line == "\n" { + if matches!((bytes_read, line.as_str()), (0, _) | (_, "\r\n" | "\n")) { break; } } diff --git a/installer/src/main.rs b/installer/src/main.rs index 574a3b9bd..ce7a14f68 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -6,6 +6,7 @@ mod install_flow; mod staged_suite; +mod tracing_config; #[cfg(test)] use crate::install_flow::ensure_dylint_tools_with_options; @@ -17,7 +18,6 @@ use camino::{Utf8Path, Utf8PathBuf}; use clap::Parser; use std::io::Write; use std::time::Instant; -use tracing::Level; use whitaker_installer::cli::{Cli, Command, InstallArgs}; use whitaker_installer::crate_name::CrateName; use whitaker_installer::deps::{SourcePolicy, SystemCommandExecutor}; @@ -33,9 +33,10 @@ use whitaker_installer::resolution::{ use whitaker_installer::toolchain::Toolchain; use whitaker_installer::wrapper::{generate_wrapper_scripts, path_instructions}; +/// Parse options, configure tracing, and run the selected command. fn main() { let cli = Cli::parse(); - configure_tracing(cli.install_args()); + tracing_config::configure_tracing(cli.install_args()); let mut stdout = std::io::stdout(); let mut stderr = std::io::stderr(); let run_result = run(&cli, &mut stdout, &mut stderr); @@ -45,25 +46,6 @@ fn main() { } } -/// Enable bounded stderr diagnostics according to install verbosity flags. -/// -/// Quiet mode leaves only error-level events enabled; default mode enables -/// warnings, while `-v` also exposes debug diagnostics. Formatting omits ANSI -/// escapes for predictable redirected output. -fn configure_tracing(args: &InstallArgs) { - let max_level = if args.quiet { - Level::ERROR - } else if args.verbosity > 0 { - Level::DEBUG - } else { - Level::WARN - }; - let _ = tracing_subscriber::fmt() - .with_ansi(false) - .with_max_level(max_level) - .try_init(); -} - /// Routes CLI commands to their respective handlers. fn run(cli: &Cli, stdout: &mut dyn Write, stderr: &mut dyn Write) -> Result<()> { match &cli.command { diff --git a/installer/src/tracing_config.rs b/installer/src/tracing_config.rs new file mode 100644 index 000000000..3c61b7041 --- /dev/null +++ b/installer/src/tracing_config.rs @@ -0,0 +1,128 @@ +//! Configure and test the installer CLI's diagnostic subscriber. + +use std::io::Stderr; + +use tracing::Level; +use tracing_subscriber::{ + filter::LevelFilter, + fmt::{ + SubscriberBuilder, + format::{DefaultFields, Format}, + }, +}; +use whitaker_installer::cli::InstallArgs; + +/// Enable bounded stderr diagnostics according to install verbosity flags. +pub(crate) fn configure_tracing(args: &InstallArgs) { + let _ = tracing_formatter(args).try_init(); +} + +/// Build the CLI-only formatter without installing a global subscriber. +/// +/// Keep construction separate so tests can capture events using the production +/// filter and ANSI policy. Only CLI startup and this module's tests use it. +fn tracing_formatter( + args: &InstallArgs, +) -> SubscriberBuilder Stderr> { + let max_level = if args.quiet { + Level::ERROR + } else if args.verbosity > 0 { + Level::DEBUG + } else { + Level::WARN + }; + tracing_subscriber::fmt() + .with_ansi(false) + .with_max_level(max_level) + .with_writer(std::io::stderr as fn() -> Stderr) +} + +#[cfg(test)] +mod tests { + //! Exercise the production formatter without global subscriber mutation. + + use std::io::{Read, Seek}; + + use rstest::rstest; + + use super::*; + + /// Verify CLI initialization sends diagnostics to stderr in its own process. + #[test] + fn tracing_writes_to_stderr() { + const PROBE_ENV: &str = "WHITAKER_TRACING_STDERR_PROBE"; + if std::env::var_os(PROBE_ENV).is_some() { + configure_tracing(&InstallArgs::default()); + tracing::warn!("installer stderr diagnostic"); + return; + } + let output = std::process::Command::new( + std::env::current_exe().expect("test executable must be discoverable"), + ) + .args([ + "--exact", + "tracing_config::tests::tracing_writes_to_stderr", + "--nocapture", + ]) + .env(PROBE_ENV, "1") + .output() + .expect("isolated tracing probe must run"); + assert!( + output.status.success(), + "tracing probe must succeed: {output:?}" + ); + let stderr = String::from_utf8(output.stderr).expect("stderr must be UTF-8"); + let stdout = String::from_utf8(output.stdout).expect("stdout must be UTF-8"); + assert!(stderr.contains("installer stderr diagnostic")); + assert!(!stdout.contains("installer stderr diagnostic")); + assert!(!stderr.contains('\u{1b}'), "stderr must omit ANSI escapes"); + } + + /// Verify each verbosity policy through emitted, formatted events. + #[rstest] + #[case::quiet(true, 0, &["error diagnostic"])] + #[case::default(false, 0, &["error diagnostic", "warning diagnostic"])] + #[case::verbose(false, 1, &["error diagnostic", "warning diagnostic", "info diagnostic", "debug diagnostic"])] + #[case::repeated_verbose(false, 2, &["error diagnostic", "warning diagnostic", "info diagnostic", "debug diagnostic"])] + #[case::quiet_overrides_verbose(true, 2, &["error diagnostic"])] + fn tracing_respects_verbosity_without_ansi( + #[case] quiet: bool, + #[case] verbosity: u8, + #[case] expected_messages: &[&str], + ) { + let args = InstallArgs { + quiet, + verbosity, + ..InstallArgs::default() + }; + let mut capture = tempfile::tempfile().expect("diagnostic capture file must be created"); + let subscriber = tracing_formatter(&args) + .with_writer(capture.try_clone().expect("capture file must be cloned")) + .finish(); + tracing::subscriber::with_default(subscriber, || { + tracing::error!("error diagnostic"); + tracing::warn!("warning diagnostic"); + tracing::info!("info diagnostic"); + tracing::debug!("debug diagnostic"); + tracing::trace!("trace diagnostic"); + }); + capture.rewind().expect("capture file must rewind"); + let mut output = String::new(); + capture + .read_to_string(&mut output) + .expect("formatted diagnostics must be UTF-8"); + let messages = output + .lines() + .map(|line| { + line.split_once(": ") + .expect("event must include its formatted message") + .1 + }) + .collect::>(); + assert_eq!(messages, expected_messages); + assert!( + !output.contains('\u{1b}'), + "diagnostics must omit ANSI escapes" + ); + } +} diff --git a/typos.local.toml b/typos.local.toml index c76b1dd49..d0020aad1 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -22,6 +22,10 @@ ignore = [ "(?m)^ !json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", "(?m)^ json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", "(?m)^ json\\.contains\\(\"\\\\\"artifactLocation\\\\\"\"\\),$", + # Keep schema keys exempt only when they appear as quoted wire values; keep + # the removed field spelling only in its compile-fail regression fixture. + "(?m)^.*\\\"(?:artifactLocation|artifacts)\\\".*$", + "(?m)^.*run\\.artifacts;.*$", "(?m)^ \"\\-\\-artifact\\-server\\-path\",$", "(?m)^ let Message::CompilerArtifact\\(artefact\\) = message else \\{$", "(?m)^ let Ok\\(Message::CompilerArtifact\\(artefact\\)\\) = message else \\{$", @@ -68,9 +72,9 @@ ignore = [ "(?m)^error: Tha gnรฌomh std::fs `std::fs::read` aโ€™ seachnadh aโ€™ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", "(?m)^module_max_lines = Tha mรฒideal \\{ \\$module \\} aโ€™ leudachadh gu \\{ \\$lines \\} loidhnichean agus aโ€™ briseadh an crรฌoch \\{ \\$limit \\}\\.$", "(?m)^no_std_fs_operations = Tha gnรฌomh std::fs `\\{ \\$operation \\}` aโ€™ seachnadh aโ€™ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", - # Legacy public names remain only in compile-fail contracts; SARIF wire keys - # retain the schema's spelling. - "\\b(?:normalise_locale|rasterise_signal|normalise_settings|Artifact|ArtifactLocation|with_artifact|artifact_location|artifactLocation|artifacts)\\b", + # Legacy public names remain only in compile-fail contracts. SARIF wire keys + # use the line-specific exceptions above so they cannot exempt prose. + "\\b(?:normalise_locale|rasterise_signal|normalise_settings|Artifact|ArtifactLocation|with_artifact|artifact_location)\\b", # Inline code spans are masked; the shared dictionary does not mask them yet. '`[^`\n]+`', ] diff --git a/typos.toml b/typos.toml index b33fee1c1..a7666e440 100644 --- a/typos.toml +++ b/typos.toml @@ -34,92 +34,21 @@ extend-exclude = [ [default] locale = "en-gb" extend-ignore-re = [ - "(?m)^ \\.context\\(\"failed to serialise diagnostic for snapshot\"\\),$", "(?m)^ !json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", - "(?m)^ Ok\\(parsed\\) => assert_eq!\\(artifact, parsed\\),$", - "(?m)^ \\.artifact_location$", - "(?m)^ artifact_location: ArtifactLocation \\{$", - "(?m)^ artifact_location: whitaker_sarif::ArtifactLocation \\{$", - "(?m)^ assert!\\(!json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\)\\);$", - "(?m)^ format!\\(\"bumpy\\-road signal rasterisation failed: \\{error\\}\"\\),$", - "(?m)^ Ok\\(json\\) => match serde_json::from_str::\\(&json\\) \\{$", - "(?m)^ \\.artifact_location$", + "(?m)^ json\\.contains\\(\"\\\\\"artifactLocation\\\\\"\"\\),$", + "(?m)^ json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", "(?m)^ \\.env_remove\\(env::CARGO_TERM_COLOR\\)$", - "(?m)^ \\.expect\\(\"Fluent source should be initialised\"\\);$", - "(?m)^ \\.unwrap_or_else\\(\\|\\| panic!\\(\"localizer should be initialised\"\\)\\)$", - "(?m)^ artifacts: Vec::new\\(\\),$", - "(?m)^ artifacts: self\\.artifacts,$", - "(?m)^ localised_messages\\(&FailingLookup::new\\(\"no_std_fs_operations\"\\), &op\\)$", - "(?m)^ localised_messages\\(localizer, &op\\)$", - "(?m)^ location: ArtifactLocation \\{$", - "(?m)^ location\\.physical_location\\.artifact_location\\.uri, file_uri,$", "(?m)^ \"\\-\\-artifact\\-server\\-path\",$", - "(?m)^ \"failed to initialise isolated rustup: \\{\\}\",$", - "(?m)^ TraitItemMetrics::default_method\\(\"serialise\", 8\\),$", - "(?m)^ \\.expect\\(\"failed to initialise isolated rustup environment\"\\);$", - "(?m)^ \\.expect\\(\"settings should be normalised\"\\);$", - "(?m)^ \\.filter_map\\(capitalise_segment\\)$", - "(?m)^ \\.normalised$", - "(?m)^ all_artifacts\\.extend\\(run\\.artifacts\\.clone\\(\\)\\);$", - "(?m)^ artifacts: all_artifacts,$", - "(?m)^ assert_eq!\\(loc\\.physical_location\\.artifact_location\\.uri, \"src/main\\.rs\"\\);$", - "(?m)^ assert_eq!\\(location\\.physical_location\\.artifact_location\\.uri, file_uri\\);$", - "(?m)^ assert_eq!\\(normalise_locale\\(input\\), expected\\);$", - "(?m)^ capitalised\\.push\\(character\\.to_ascii_lowercase\\(\\)\\);$", - "(?m)^ depth: normalise_weight\\(settings\\.weights\\.depth, defaults\\.weights\\.depth\\),$", - "(?m)^ flow: normalise_weight\\(settings\\.weights\\.flow, defaults\\.weights\\.flow\\),$", "(?m)^ let Message::CompilerArtifact\\(artefact\\) = message else \\{$", "(?m)^ let Ok\\(Message::CompilerArtifact\\(artefact\\)\\) = message else \\{$", - "(?m)^ let artefact = Artifact \\{$", - "(?m)^ let artifact = Artifact \\{$", - "(?m)^ let summary = summarise_context\\($", - "(?m)^ let summary = summarise_context\\(entries\\.as_slice\\(\\), has_test_context_ancestry, additional\\);$", - "(?m)^ let summary = summarise_context_with_harness\\($", - "(?m)^ localised_messages\\(&lookup, kind, attribute\\)$", - "(?m)^ match serde_json::to_string\\(&artifact\\) \\{$", - "(?m)^ normalise_locale\\(self\\.locale\\.as_deref\\(\\)\\)$", - "(?m)^ predicate: normalise_weight\\(settings\\.weights\\.predicate, defaults\\.weights\\.predicate\\),$", - "(?m)^ result\\.locations\\[0\\]\\.physical_location\\.artifact_location\\.uri,$", - "(?m)^ self\\.artifacts\\.push\\(artifact\\);$", - "(?m)^ self\\.settings = normalise_settings\\(load_configuration\\(\\)\\.into_settings\\(\\)\\);$", - "(?m)^ uses: actions/download\\-artifact@v4$", "(?m)^ uses: actions/upload\\-artifact@v4$", "(?m)^ uses: actions/upload\\-artifact@v7$", - "(?m)^ world\\.with_localizer\\(\\|localizer\\| localised_messages\\(localizer, kind, attribute\\)\\)$", - "(?m)^ artifact_dir = tmp_path / \"act\\-artifacts\"$", "(?m)^ \\- Download all artefacts \\(actions/download\\-artifact@v4,$", "(?m)^ \\- Upload artefact \\(actions/upload\\-artifact@v4,$", - "(?m)^ /// Appends an artifact reference\\.$", + "(?m)^ #\\[serde\\(default, rename = \"artifacts\", skip_serializing_if = \"Vec::is_empty\"\\)\\]$", + "(?m)^ #\\[serde\\(rename = \"artifactLocation\"\\)\\]$", "(?m)^ /// Categorises the input with a single cluster of conditional logic\\.$", - "(?m)^ /// Identifies the artifact \\(file\\)\\.$", - "(?m)^ /// Location of the artifact\\.$", - "(?m)^ /// Optional byte offset from the start of the artifact\\.$", - "(?m)^ /// Optional region within the artifact\\.$", - "(?m)^ /// Referenced source artifacts\\.$", - "(?m)^ /// Relative or absolute URI of the artifact\\.$", - "(?m)^ /// Returns the source artifact URI used in SARIF output\\.$", - "(?m)^ /// Sets the byte offset from the start of the artifact\\.$", - "(?m)^ /// Sets the region within the artifact\\.$", - "(?m)^ /// assert_eq!\\(run\\.artifacts\\.len\\(\\), 1\\);$", "(?m)^ /// executed \\(catches mis\\-specified scenarios missing the When step\\)\\.$", - "(?m)^ And I normalise the settings$", - "(?m)^ And a default method normalise with CC 7$", - "(?m)^ Artifact, ArtifactLocation, Invocation, Level, Location, Message, MultiformatMessageString,$", - "(?m)^ ContextLabel, Localizer, NoExpectMessages, ReceiverCategory, ReceiverLabel, localised_messages,$", - "(?m)^ DEFAULT_THRESHOLD, Settings, Weights, detect_bumps, normalise_settings, top_two_bumps,$", - "(?m)^ Given the lint recognises custom::test as a test attribute$", - "(?m)^ Some\\(capitalised\\)$", - "(?m)^ Then the function is recognised as an rstest fixture$", - "(?m)^ Then the function is recognised as an rstest test$", - "(?m)^ Then the function is recognised as not being an rstest test$", - "(?m)^ Then the function is recognised as not test\\-like$", - "(?m)^ Then the function is recognised as test\\-like$", - "(?m)^ When I localise the diagnostic$", - "(?m)^ When I localise the expect diagnostic$", - "(?m)^ When I localise the std::fs diagnostic$", - "(?m)^ When I summarise the context$", - "(?m)^ When the complexity is finalised$", - "(?m)^ \\.contains\\(\"unrecognised boolean value 'maybe'\"\\)\\);$", "(?m)^ \\.help = Cuir beachd doc ris a tha aโ€™ mรฌneachadh giรนlan \\{ \\$function \\}\\.$", "(?m)^ \\.help = Cuir seachad `cap_std::fs::Dir` agus paramadairean `camino::Utf8Path`/`Utf8PathBuf` tron API seach std::fs a ghairm gu dรฌreach\\.$", "(?m)^ \\.label = Tha bump iom\\-fhillteachd \\{ \\$index \\} aโ€™ leudachadh thairis air \\{ \\$lines \\} \\{ \\$lines \\->$", @@ -131,111 +60,25 @@ extend-ignore-re = [ "(?m)^ \\.note = Tha mรฒidealan mรฒra nas duilghe an ath\\-sgrรนdadh\\.$", "(?m)^ \\.note = Tha na docs airson \\{ \\$test \\} aโ€™ gabhail a\\-steach ceann eisimpleirean no bloca cรฒd le feansa\\.$", "(?m)^ \\.note = Tha teachdaireachdan airson \\{ \\$lint \\} ri fhaighinn ann an Gร idhlig\\.$", - "(?m)^ \\.note = The call originates within \\{ \\$context \\} which is not recognised as a test\\.$", "(?m)^ artefact: &cargo_metadata::Artifact,$", - "(?m)^ artifact_dir = tmp_path / \"act\\-artifacts\"$", - "(?m)^ artifacts: Vec,$", - "(?m)^ assert_eq!\\(normalised, expected\\);$", - "(?m)^ builder\\.add_default_method\\(\"normalise\", 7, false\\);$", - "(?m)^ capitalised\\.push\\(first\\.to_ascii_uppercase\\(\\)\\);$", - "(?m)^ context_label, fallback_messages, localised_messages,$", - "(?m)^ fn artifact_round_trip\\(\\) \\{$", - "(?m)^ fn deserialises_locale_override\\(\\) \\{$", - "(?m)^ fn deserialises_overrides_from_toml\\(\\) \\{$", - "(?m)^ fn normalise_weight\\(candidate: f64, fallback: f64\\) \\-> f64 \\{$", - "(?m)^ fn normalises_candidates\\(#\\[case\\] input: Option<&str>, #\\[case\\] expected: Option<&str>\\) \\{$", - "(?m)^ fn propagates_deserialisation_failures\\(\\) \\{$", - "(?m)^ fn success_message_pluralises_correctly\\(#\\[case\\] count: usize, #\\[case\\] expected: &str\\) \\{$", - "(?m)^ let Some\\(expected_value\\) = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\) else \\{$", "(?m)^ let _ = fixture::categorise\\(0\\);$", "(?m)^ let _ = fixture::categorise\\(4\\);$", "(?m)^ let _ = fixture::categorise\\(99\\);$", - "(?m)^ let candidate = normalise_locale\\(raw\\)\\?;$", - "(?m)^ let expected = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\)$", - "(?m)^ let expected_value = normalise_locale\\(Some\\(raw\\.as_str\\(\\)\\)\\)$", - "(?m)^ let file = location\\.physical_location\\.artifact_location\\.uri\\.clone\\(\\);$", - "(?m)^ let help = normalise_isolation_marks\\(messages\\.help\\(\\)\\);$", - "(?m)^ let messages = localised_messages\\(&lookup, &receiver_label, &context_label, category\\)$", - "(?m)^ let mut all_artifacts = Vec::new\\(\\);$", - "(?m)^ let mut capitalised = String::with_capacity\\(segment\\.len\\(\\)\\);$", - "(?m)^ let mut summary = crate::context::summarise_context\\(cx, hir_id\\);$", - "(?m)^ let normalised = normalise_settings\\(settings\\);$", - "(?m)^ let normalized = normalise_settings\\(settings\\);$", - "(?m)^ let note = format!\\(\"The call originates within \\{context\\} which is not recognised as a test\\.\",\\);$", - "(?m)^ let note = normalise_isolation_marks\\(messages\\.note\\(\\)\\);$", - "(?m)^ let primary = normalise_isolation_marks\\(messages\\.primary\\(\\)\\);$", - "(?m)^ let summary = summarise_context\\(&entries, false, &\\[\\]\\);$", - "(?m)^ let summary = summarise_context\\(&entries, false, additional\\.as_slice\\(\\)\\);$", - "(?m)^ let summary = summarise_context\\(&entries, true, &\\[\\]\\);$", - "(?m)^ localised_messages,$", - "(?m)^ localised_messages\\(lookup, receiver, &context, category\\)$", - "(?m)^ normalise_locale, resolve_localizer, safe_resolve_message_set, supports_locale,$", - "(?m)^ normalised: RefCell>,$", - "(?m)^ pub artifact_location: ArtifactLocation,$", - "(?m)^ pub artifacts: Vec,$", "(?m)^ pub fn categorise\\(input: i32\\) \\-> &'static str \\{$", - "(?m)^ pub fn with_artifact\\(mut self, artifact: Artifact\\) \\-> Self \\{$", - "(?m)^ pub location: super::location::ArtifactLocation,$", "(?m)^ types: \\[opened, reopened, synchronize, labeled, ready_for_review\\]$", - "(?m)^ use crate::model::location::ArtifactLocation;$", - "(?m)^ validate_pluralisation_coverage\\(locale, max_branches\\);$", - "(?m)^ world\\.normalised\\.replace\\(Some\\(normalised\\)\\);$", "(?m)^ = help: Cuir seachad `cap_std::fs::Dir` agus paramadairean `camino::Utf8Path`/`Utf8PathBuf` tron API seach std::fs a ghairm gu dรฌreach\\.$", - "(?m)^ = note: The call originates within function `fail_result` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `handler` which is not recognised as a test\\.$", "(?m)^ = note: The call originates within function `helper` which is not recognised as a test\\.$", "(?m)^ = note: The call originates within function `load` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `main` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `parse_config` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `parse` which is not recognised as a test\\.$", - "(?m)^ = note: The call originates within function `process` which is not recognised as a test\\.$", "(?m)^ = note: The call originates within function `production` which is not recognised as a test\\.$", "(?m)^ CARGO_TERM_COLOR: always$", - "(?m)^ Scenario: Function recognised via configured attribute$", - "(?m)^ Scenario: Recognise configured custom test attribute$", - "(?m)^ Scenario: Recognise rstest decorated functions$", - "(?m)^ Scenario: Recognise tokio::test decorated functions$", - "(?m)^ Scenario: Type methods split into parsing, serialisation, and filesystem groups$", - "(?m)^ Whitaker lints require localised diagnostics with predictable fallbacks\\.$", "(?m)^ `download\\-artifact` merge step would clobber all but the last\\.$", - "(?m)^# Borrowed English nouns typically pluralise with \\-iau \\(Modern Welsh, Gareth$", - "(?m)^# Serialise dylint UI tests that build lint libraries and use$", - "(?m)^# Serialise ignored exclusion integration tests when they are explicitly run\\.$", - "(?m)^## Artifacts and Notes$", - "(?m)^## Artifacts and notes$", + "(?m)^# Borrowed English nouns typically pluralize with \\-iau \\(Modern Welsh, Gareth$", "(?m)^## Cuidhtearan breithneachaidh ga roinn thar linteran Whitaker\\.$", "(?m)^## Seachain `unwrap_or_else` a tha aโ€™ clisgeadh\\.$", "(?m)^## Tha `expect` toirmisgte taobh a\\-muigh deuchainnean\\.$", "(?m)^#\\. Air a shealltainn ann an breithneachaidhean nuair a tha e aโ€™ toirt iomradh air am buadh roimhe\\.$", - "(?m)^#\\[given\\(\"the lint recognises \\{path\\} as a test attribute\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as an rstest fixture\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as an rstest test\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as not being an rstest test\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as not test\\-like\"\\)\\]$", - "(?m)^#\\[then\\(\"the function is recognised as test\\-like\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the expect diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I localise the std::fs diagnostic\"\\)\\]$", - "(?m)^#\\[when\\(\"I normalise the settings\"\\)\\]$", - "(?m)^#\\[when\\(\"I summarise the context\"\\)\\]$", - "(?m)^#\\[when\\(\"the complexity is finalised\"\\)\\]$", - "(?m)^//! SARIF run, tool, invocation, and artifact types\\.$", - "(?m)^//! The fixture config recognises `#\\[tokio::test\\]`, but `parse_config` remains$", - "(?m)^/// artifacts: Vec::new\\(\\),$", - "(?m)^/// A location within an artifact \\(source file\\)\\.$", - "(?m)^/// A reference to an artifact by URI\\.$", - "(?m)^/// A region within an artifact, identified by line and column numbers\\.$", - "(?m)^/// A source artifact referenced by results\\.$", - "(?m)^/// and deduplicated\\. Artifacts and invocations are concatenated\\.$", - "(?m)^/// arbitrary `const`\\-only module for a synthesised rstest harness descriptor\\.$", - "(?m)^/// assert_eq!\\(artifact\\.location\\.uri, \"src/main\\.rs\"\\);$", - "(?m)^/// let artifact = Artifact \\{$", - "(?m)^Feature: Localised diagnostics for expect usage$", - "(?m)^Feature: Localised diagnostics for function attribute ordering$", - "(?m)^Feature: Localised diagnostics for std::fs usage$", - "(?m)^Feature: Localiser helpers$", - "(?m)^Feature: Summarise traversal context for `\\.expect\\(\\.\\.\\)` linting$", - "(?m)^\\- Helpers to build rules, results, locations, artifacts, and invocations\\.$", + "(?m)^.*\\\"(?:artifactLocation|artifacts)\\\".*$", + "(?m)^.*run\\.artifacts;.*$", "(?m)^\\- Lexical segment matching can mis\\-handle renamed imports, glob imports, or$", "(?m)^\\- `\\-\\-artifact\\-server\\-path `: export uploaded artefacts to a host$", "(?m)^bumpy_road_function = Tha iomadh cruinneachadh de loidsig chumhachail neadaichte ann an `\\{ \\$name \\}`\\.$", @@ -243,45 +86,8 @@ extend-ignore-re = [ "(?m)^cargo dylint list \\-\\-color never \\| Where\\-Object \\{\\{$", "(?m)^cargo dylint list \\-\\-color never \\| awk \\-v suite=\"\\{suite_crate\\}\" '\\$0 ~ \"\\^\" suite \"\\(\\[\\[:space:\\]\\]\\|\\$\\)\" \\{\\{ print \\}\\}'$", "(?m)^error: Tha gnรฌomh std::fs `std::fs::read` aโ€™ seachnadh aโ€™ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", - "(?m)^fn capitalise_segment\\(segment: &str\\) \\-> Option \\{$", - "(?m)^fn localised_help_attributes_are_complete\\(\\) \\{$", - "(?m)^fn localised_messages\\($", - "(?m)^fn normalise_isolation_marks\\(text: &str\\) \\-> String \\{$", - "(?m)^fn normalise_settings_falls_back_to_defaults\\($", - "(?m)^fn pluralisation_covers_sample_range\\(#\\[case\\] locale: &str, #\\[case\\] max_branches: i64\\) \\{$", - "(?m)^fn recognises_std_fs_paths\\(#\\[case\\] path: &str, #\\[case\\] expected: bool\\) \\{$", - "(?m)^fn recognises_test_attribute\\(\\) \\{$", - "(?m)^fn resolve_additional_components_parametrised\\(#\\[case\\] cranelift: bool, #\\[case\\] expected: &\\[&str\\]\\) \\{$", - "(?m)^fn scenario_recognises_custom\\(function: FunctionFixture, evaluation: Evaluation\\) \\{$", - "(?m)^fn summarise_context_with_harness<'tcx>\\($", - "(?m)^fn summarises_plain_context\\(\\) \\{$", - "(?m)^fn validate_pluralisation_coverage\\(locale: &str, max_branches: i64\\) \\{$", - "(?m)^fn when_finalised\\(world: &CcWorld\\) \\{$", - "(?m)^fn when_localise\\(world: &WorldCell\\) \\{$", - "(?m)^fn when_normalise\\(world: &World\\) \\{$", - "(?m)^fn when_summarise\\(world: &ContextWorld\\) \\{$", "(?m)^module_max_lines = Tha mรฒideal \\{ \\$module \\} aโ€™ leudachadh gu \\{ \\$lines \\} loidhnichean agus aโ€™ briseadh an crรฌoch \\{ \\$limit \\}\\.$", "(?m)^no_std_fs_operations = Tha gnรฌomh std::fs `\\{ \\$operation \\}` aโ€™ seachnadh aโ€™ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", - "(?m)^pub fn normalise_locale\\(input: Option<&str>\\) \\-> Option<&str> \\{$", - "(?m)^pub fn normalise_settings\\(settings: Settings\\) \\-> Settings \\{$", - "(?m)^pub fn rasterise_signal\\($", - "(?m)^pub struct Artifact \\{$", - "(?m)^pub struct ArtifactLocation \\{$", - "(?m)^pub use location::\\{ArtifactLocation, Location, PhysicalLocation, Region, RelatedLocation\\};$", - "(?m)^pub use run::\\{Artifact, Invocation, Run, Tool, ToolComponent\\};$", - "(?m)^pub use selection::\\{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer\\};$", - "(?m)^pub\\(crate\\) fn localised_messages\\($", - "(?m)^pub\\(crate\\) fn summarise_context<'tcx>\\($", - "(?m)^pub\\(crate\\) fn summarise_context\\($", - "(?m)^use crate::analysis::\\{Settings, detect_bumps, normalise_settings\\};$", - "(?m)^use crate::context::\\{ContextSummary, summarise_context\\};$", - "(?m)^use crate::context::\\{collect_context, is_cfg_test_attribute, summarise_context\\};$", - "(?m)^use crate::context::summarise_context;$", - "(?m)^use crate::diagnostics::\\{StdFsMessages, localised_messages\\};$", - "(?m)^use crate::model::location::\\{ArtifactLocation, Location, PhysicalLocation, Region\\};$", - "(?m)^use crate::model::run::\\{Artifact, Invocation, Run, Tool, ToolComponent\\};$", - "(?m)^use whitaker_common::i18n::\\{LocaleSelection, LocaleSource, normalise_locale, resolve_localizer\\};$", - "(?m)^use whitaker_common::i18n::normalise_locale;$", "(?s)```.*?```", "--[A-Za-z0-9_-]*color(?:ed|s|ing|ize[ds]?|izing)?(?:\\b|_)", "Azure\\s+Architecture\\s+Center\\s*\\|\\s*Microsoft\\s+Learn\\b", @@ -290,7 +96,7 @@ extend-ignore-re = [ "How to organise your Rust tests", "\\b(?:background|theme)_color\\b", "\\b(?:carousel|dropdown|footer|indicator|items|navbar|toast)-center\\b", - "\\b(?:normalise_locale|rasterise_signal|normalise_settings|Artifact|ArtifactLocation|with_artifact|artifact_location|artifactLocation|artifacts)\\b", + "\\b(?:normalise_locale|rasterise_signal|normalise_settings|Artifact|ArtifactLocation|with_artifact|artifact_location)\\b", "\\bAppFactory Date: Thu, 15 Oct 2026 03:45:24 +0200 Subject: [PATCH 17/32] Address PR #363 review findings Cover renamed API compatibility and SARIF wire contracts in the compile-fail and pass fixtures. Validate installer diagnostics and tracing, document user-visible behavior, and bound stale UI harness lock recovery so abandoned owners cannot stall later builds. Refresh spelling exceptions and document the Markdown linter executable override contract. --- Makefile | 2 + common/src/brain_trait_metrics/tests.rs | 2 + common/src/i18n/selection.rs | 1 + common/tests/public_api_contract.rs | 1 + common/tests/ui/legacy_common_apis.rs | 1 + common/tests/ui/pass_renamed_common_apis.rs | 1 + .../tests/analysis_behaviour.rs | 2 + .../bumpy_road_function/tests/api_contract.rs | 1 + .../tests/api_ui/legacy_normalize_settings.rs | 1 + .../tests/api_ui/pass_normalize_settings.rs | 1 + .../src/tests/localization.rs | 4 + crates/module_max_lines/src/driver.rs | 1 + .../no_expect_outside_tests/src/behaviour.rs | 1 + crates/no_expect_outside_tests/src/tests.rs | 4 + .../src/tests/localization.rs | 4 + .../src/tests/receiver_type_edge_cases.rs | 1 + crates/no_std_fs_operations/src/behaviour.rs | 1 + .../src/exclusion_behaviour.rs | 1 + .../no_std_fs_operations/src/usage/tests.rs | 2 + .../tests/ui/harness_lock.rs | 9 +- .../src/behaviour.rs | 1 + .../whitaker_clones_core/src/index/tests.rs | 3 + crates/whitaker_clones_core/src/run0/tests.rs | 1 + .../tests/run0_sarif_behaviour.rs | 1 + .../src/builders/location_builder.rs | 2 + .../src/builders/log_builder.rs | 1 + crates/whitaker_sarif/src/model/location.rs | 2 + crates/whitaker_sarif/src/model/run.rs | 3 + crates/whitaker_sarif/tests/api_contract.rs | 1 + .../tests/ui/legacy_sarif_api.rs | 1 + .../tests/ui/legacy_sarif_api.stderr | 8 +- .../tests/ui/legacy_sarif_fields.rs | 1 + .../tests/ui/legacy_sarif_fields.stderr | 24 ++--- .../tests/ui/pass_renamed_sarif_api.rs | 1 + docs/users-guide.md | 12 +++ installer/src/artefact/extraction.rs | 2 + installer/src/artefact/schema_version.rs | 1 + installer/src/artefact/sha256_digest.rs | 1 + installer/src/deps/mod.rs | 1 + installer/src/output.rs | 1 + installer/src/tracing_config.rs | 88 +++++++++++++++++++ src/config.rs | 3 + tests/locale_resolution.rs | 2 + tests/ui_harness.rs | 1 + typos.local.toml | 8 +- typos.toml | 4 +- 46 files changed, 190 insertions(+), 25 deletions(-) diff --git a/Makefile b/Makefile index 5838c9afa..afc340ffc 100644 --- a/Makefile +++ b/Makefile @@ -80,6 +80,8 @@ SCRATCH_ROOT_CHECK = mkdir -p -m 700 "$(SCRATCH_ROOT)"; \ exit 1; \ fi RUSTDOC_FLAGS ?= --cfg docsrs -D warnings +# `markdownlint` passes MDLINT to xargs as one command word, so overrides must +# name a single executable path rather than include command-line arguments. MDLINT ?= $(shell command -v markdownlint-cli2 2>/dev/null || printf '%s' "$$HOME/.bun/bin/markdownlint-cli2") # `make fmt` and `make check-fmt` call mdtablefix directly. `--git` selects the # Markdown files Git tracks and `--include-untracked` adds the untracked files diff --git a/common/src/brain_trait_metrics/tests.rs b/common/src/brain_trait_metrics/tests.rs index e0e5965b2..697147801 100644 --- a/common/src/brain_trait_metrics/tests.rs +++ b/common/src/brain_trait_metrics/tests.rs @@ -91,6 +91,7 @@ fn default_method_count_excludes_required_and_associated_items() { assert_eq!(default_method_count(&mixed_items()), 1); } +/// Verify that the complexity sum includes only default methods. #[rstest] fn default_method_cc_sum_aggregates_only_default_methods() { let items = vec![ @@ -186,6 +187,7 @@ fn builder_filters_macro_expanded_default_methods() { ); } +/// Check that implementor burden equals the number of required methods. #[rstest] fn implementor_burden_equals_required_method_count() { let mut builder = TraitMetricsBuilder::new("Transformer"); diff --git a/common/src/i18n/selection.rs b/common/src/i18n/selection.rs index dbea901b2..c6a58b3e7 100644 --- a/common/src/i18n/selection.rs +++ b/common/src/i18n/selection.rs @@ -217,6 +217,7 @@ mod tests { assert_eq!(selection.used_fallback(), case.expected_fallback); } + /// Check locale normalization for empty, padded, and valid candidates. #[rstest] #[case(None, None)] #[case(Some(""), None)] diff --git a/common/tests/public_api_contract.rs b/common/tests/public_api_contract.rs index 7eb6e238e..9e5ce872f 100644 --- a/common/tests/public_api_contract.rs +++ b/common/tests/public_api_contract.rs @@ -1,5 +1,6 @@ //! Compile-time contracts for common public APIs changed to Oxford spelling. +/// Verify that renamed common APIs compile and their legacy names remain removed. #[test] fn renamed_common_apis_are_public_and_legacy_names_stay_removed() { let cases = trybuild::TestCases::new(); diff --git a/common/tests/ui/legacy_common_apis.rs b/common/tests/ui/legacy_common_apis.rs index bd5e2f9fa..6cd941d56 100644 --- a/common/tests/ui/legacy_common_apis.rs +++ b/common/tests/ui/legacy_common_apis.rs @@ -3,6 +3,7 @@ use whitaker_common::complexity_signal::rasterise_signal; use whitaker_common::normalise_locale; +/// Exercise removed common API names in the compile-fail fixture. fn main() { let _ = normalise_locale(Some("en-GB")); let _ = rasterise_signal(1..=1, &[]); diff --git a/common/tests/ui/pass_renamed_common_apis.rs b/common/tests/ui/pass_renamed_common_apis.rs index a9d2a5a6b..f321fc98d 100644 --- a/common/tests/ui/pass_renamed_common_apis.rs +++ b/common/tests/ui/pass_renamed_common_apis.rs @@ -3,6 +3,7 @@ use whitaker_common::complexity_signal::{LineSegment, rasterize_signal}; use whitaker_common::normalize_locale; +/// Exercise the renamed common APIs in the compile-pass fixture. fn main() { let locale = normalize_locale(Some(" en-GB ")); let segments: Vec<_> = LineSegment::new(1, 1, 1.0).into_iter().collect(); diff --git a/crates/bumpy_road_function/tests/analysis_behaviour.rs b/crates/bumpy_road_function/tests/analysis_behaviour.rs index 23255aa2f..fe6548ca6 100644 --- a/crates/bumpy_road_function/tests/analysis_behaviour.rs +++ b/crates/bumpy_road_function/tests/analysis_behaviour.rs @@ -58,6 +58,7 @@ use rstest_bdd_macros::{given, scenario, then, when}; }, Settings::default(), )] +/// Check that invalid settings values fall back to their defaults. fn normalize_settings_falls_back_to_defaults( #[case] settings: Settings, #[case] expected: Settings, @@ -171,6 +172,7 @@ fn when_set_threshold(world: &World, threshold: f64) { world.settings.borrow_mut().threshold = threshold; } +/// Normalize the configured settings and save the result for assertions. #[when("I normalize the settings")] fn when_normalize(world: &World) { let settings = *world.settings.borrow(); diff --git a/crates/bumpy_road_function/tests/api_contract.rs b/crates/bumpy_road_function/tests/api_contract.rs index 80f865442..3364b4e78 100644 --- a/crates/bumpy_road_function/tests/api_contract.rs +++ b/crates/bumpy_road_function/tests/api_contract.rs @@ -1,5 +1,6 @@ //! Compile-time contracts for the public settings-normalization API. +/// Verify that the renamed settings API is public and its legacy name is removed. #[test] fn normalize_settings_is_public_and_the_legacy_name_stays_removed() { let cases = trybuild::TestCases::new(); diff --git a/crates/bumpy_road_function/tests/api_ui/legacy_normalize_settings.rs b/crates/bumpy_road_function/tests/api_ui/legacy_normalize_settings.rs index 0cbb68d11..3d10d6012 100644 --- a/crates/bumpy_road_function/tests/api_ui/legacy_normalize_settings.rs +++ b/crates/bumpy_road_function/tests/api_ui/legacy_normalize_settings.rs @@ -2,6 +2,7 @@ use bumpy_road_function::analysis::normalise_settings; +/// Exercise the removed legacy settings name in the compile-fail fixture. fn main() { let _ = normalise_settings(Default::default()); } diff --git a/crates/bumpy_road_function/tests/api_ui/pass_normalize_settings.rs b/crates/bumpy_road_function/tests/api_ui/pass_normalize_settings.rs index 5e2b05e40..d29224b8d 100644 --- a/crates/bumpy_road_function/tests/api_ui/pass_normalize_settings.rs +++ b/crates/bumpy_road_function/tests/api_ui/pass_normalize_settings.rs @@ -5,6 +5,7 @@ use bumpy_road_function::analysis::{Settings, normalize_settings}; +/// Exercise the renamed settings API in the compile-pass fixture. fn main() { let settings = normalize_settings(Settings::default()); assert!(settings.window > 0); diff --git a/crates/function_attrs_follow_docs/src/tests/localization.rs b/crates/function_attrs_follow_docs/src/tests/localization.rs index 45d24741a..4443ab603 100644 --- a/crates/function_attrs_follow_docs/src/tests/localization.rs +++ b/crates/function_attrs_follow_docs/src/tests/localization.rs @@ -111,6 +111,10 @@ fn resolve_attribute(world: &LocalizationWorld, failing: bool) -> String { } } +/// Return localized messages from the configured or failing lookup. +/// +/// # Errors +/// Returns the lookup error when localization fails. fn resolve_localization( world: &LocalizationWorld, kind: FunctionKind, diff --git a/crates/module_max_lines/src/driver.rs b/crates/module_max_lines/src/driver.rs index 3eda99591..797ba851c 100644 --- a/crates/module_max_lines/src/driver.rs +++ b/crates/module_max_lines/src/driver.rs @@ -250,6 +250,7 @@ mod behaviour { } } + /// Create the default module-length behaviour-test world. #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn world() -> ModuleWorld { diff --git a/crates/no_expect_outside_tests/src/behaviour.rs b/crates/no_expect_outside_tests/src/behaviour.rs index 4548e4cdf..66fb575ef 100644 --- a/crates/no_expect_outside_tests/src/behaviour.rs +++ b/crates/no_expect_outside_tests/src/behaviour.rs @@ -116,6 +116,7 @@ fn given_doctest(world: &ContextWorld) { world.mark_doctest(); } +/// Evaluate the current context and store its summary. #[when("I summarize the context")] fn when_summarize(world: &ContextWorld) { world.evaluate(); diff --git a/crates/no_expect_outside_tests/src/tests.rs b/crates/no_expect_outside_tests/src/tests.rs index a74000480..ff5d9b110 100644 --- a/crates/no_expect_outside_tests/src/tests.rs +++ b/crates/no_expect_outside_tests/src/tests.rs @@ -18,6 +18,7 @@ fn test_attribute() -> Attribute { Attribute::new(AttributePath::from("test"), AttributeKind::Outer) } +/// Check that a plain function context is not classified as test code. #[rstest] fn summarizes_plain_context() { let entries = vec![function_entry("handler", Vec::new())]; @@ -27,6 +28,7 @@ fn summarizes_plain_context() { assert_eq!(summary.function_name.as_deref(), Some("handler")); } +/// Check that the standard test attribute marks a function as test code. #[rstest] fn recognizes_test_attribute() { let entries = vec![function_entry("test_case", vec![test_attribute()])]; @@ -36,6 +38,7 @@ fn recognizes_test_attribute() { assert_eq!(summary.function_name.as_deref(), Some("test_case")); } +/// Check that a `cfg(test)` module is classified as test code. #[rstest] fn honours_cfg_test() { let entries = vec![module_entry("tests", Vec::new())]; @@ -45,6 +48,7 @@ fn honours_cfg_test() { assert_eq!(summary.function_name, None); } +/// Check that configured test attributes mark a function as test code. #[rstest] fn honours_additional_attributes() { let entries = vec![function_entry( diff --git a/crates/no_expect_outside_tests/src/tests/localization.rs b/crates/no_expect_outside_tests/src/tests/localization.rs index 3bfbc6ee1..1867fd3b5 100644 --- a/crates/no_expect_outside_tests/src/tests/localization.rs +++ b/crates/no_expect_outside_tests/src/tests/localization.rs @@ -260,6 +260,10 @@ fn then_fallback(world: &LocalizationWorld, snippet: String) { ); } +/// Return localized messages for the receiver and summarized context. +/// +/// # Errors +/// Returns the lookup error when localization fails. fn execute_localization( lookup: &impl BundleLookup, receiver: &ReceiverLabel, diff --git a/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs b/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs index 769ab60af..a991c8865 100644 --- a/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs +++ b/crates/no_expect_outside_tests/src/tests/receiver_type_edge_cases.rs @@ -6,6 +6,7 @@ use super::{ ContextLabel, Localizer, NoExpectMessages, ReceiverCategory, ReceiverLabel, localized_messages, }; +/// Check that unusual receiver labels still produce useful messages. #[rstest] #[case("", "the surrounding scope", |messages: &NoExpectMessages| !messages.primary().is_empty())] #[case( diff --git a/crates/no_std_fs_operations/src/behaviour.rs b/crates/no_std_fs_operations/src/behaviour.rs index 98b772e85..8061548d2 100644 --- a/crates/no_std_fs_operations/src/behaviour.rs +++ b/crates/no_std_fs_operations/src/behaviour.rs @@ -84,6 +84,7 @@ fn given_failure(world: &WorldCell) { world.borrow_mut().mark_failure(); } +/// Resolve the current standard-filesystem diagnostic messages. #[when("I localize the std::fs diagnostic")] fn when_localize(world: &WorldCell) { world.borrow_mut().resolve(); diff --git a/crates/no_std_fs_operations/src/exclusion_behaviour.rs b/crates/no_std_fs_operations/src/exclusion_behaviour.rs index db1e41d1f..542bdd72a 100644 --- a/crates/no_std_fs_operations/src/exclusion_behaviour.rs +++ b/crates/no_std_fs_operations/src/exclusion_behaviour.rs @@ -34,6 +34,7 @@ impl ExclusionWorld { type WorldCell = RefCell; +/// Create the default exclusion behaviour-test world. #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn world() -> WorldCell { diff --git a/crates/no_std_fs_operations/src/usage/tests.rs b/crates/no_std_fs_operations/src/usage/tests.rs index a1d91044c..5bf11f04e 100644 --- a/crates/no_std_fs_operations/src/usage/tests.rs +++ b/crates/no_std_fs_operations/src/usage/tests.rs @@ -2,6 +2,7 @@ use super::{StdFsUsage, label_is_std_fs}; use rstest::rstest; +/// Check whether each candidate label names a standard filesystem path. #[rstest] #[case("std::fs", true)] #[case("std::fs::File::open", true)] @@ -25,6 +26,7 @@ fn recognizes_std_fs_paths(#[case] path: &str, #[case] expected: bool) { assert_eq!(label_is_std_fs(path), expected); } +/// Check that the usage builder retains its operation label. #[rstest] fn usage_builder_carries_operation() { let usage = StdFsUsage::new(String::from("std::fs::read")); diff --git a/crates/rstest_helper_should_be_fixture/tests/ui/harness_lock.rs b/crates/rstest_helper_should_be_fixture/tests/ui/harness_lock.rs index 2ffd81892..e4e3a938c 100644 --- a/crates/rstest_helper_should_be_fixture/tests/ui/harness_lock.rs +++ b/crates/rstest_helper_should_be_fixture/tests/ui/harness_lock.rs @@ -19,14 +19,13 @@ use fs2::FileExt; use log::debug; use rstest::rstest; -// The example harness lock coordinates separate nextest processes. Windows CI -// can legitimately hold it for several minutes, so only remove directories -// old enough to be abandoned by a crashed process. -const EXAMPLE_HARNESS_LOCK_STALE_AFTER: Duration = Duration::from_mins(30); +// A live owner keeps the OS liveness lock, so this grace period only delays +// recovery after a process has abandoned its directory. +const EXAMPLE_HARNESS_LOCK_STALE_AFTER: Duration = Duration::from_mins(5); // Bound the default `acquire()` wait so a wedged live owner surfaces a timeout // instead of polling forever. It exceeds the stale-recovery window so genuinely // abandoned locks are reclaimed before this ceiling is reached. -const EXAMPLE_HARNESS_LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_mins(31); +const EXAMPLE_HARNESS_LOCK_ACQUIRE_TIMEOUT: Duration = Duration::from_mins(6); const EXAMPLE_HARNESS_LOCK_POLL_INTERVAL: Duration = Duration::from_millis(100); const EXAMPLE_HARNESS_LOCK_OWNER_FILENAME: &str = "owner"; const EXAMPLE_HARNESS_LOCK_LIVENESS_EXTENSION: &str = "owner-lock"; diff --git a/crates/test_must_not_have_example/src/behaviour.rs b/crates/test_must_not_have_example/src/behaviour.rs index 52535f429..38cd6135d 100644 --- a/crates/test_must_not_have_example/src/behaviour.rs +++ b/crates/test_must_not_have_example/src/behaviour.rs @@ -26,6 +26,7 @@ impl DocumentationWorld { } } +/// Create an empty world for the documentation behaviour tests. #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn world() -> DocumentationWorld { diff --git a/crates/whitaker_clones_core/src/index/tests.rs b/crates/whitaker_clones_core/src/index/tests.rs index fd99e7044..b5ad32952 100644 --- a/crates/whitaker_clones_core/src/index/tests.rs +++ b/crates/whitaker_clones_core/src/index/tests.rs @@ -34,18 +34,21 @@ fn multi_band_config() -> LshConfig { LshConfig::new(32, 4).expect("multi-band config should validate") } +/// Create the shared signature fixture used by index tests. #[allow_fixture_expansion_lints] #[fixture] fn shared_signature() -> MinHashSignature { sketch(&[1, 2, 3, 4]) } +/// Create a signature distinct from the shared fixture. #[allow_fixture_expansion_lints] #[fixture] fn distinct_signature() -> MinHashSignature { sketch(&[8, 9, 10, 11]) } +/// Create the repeated signature used to test identical fragments. #[allow_fixture_expansion_lints] #[fixture] fn identical_signature() -> MinHashSignature { diff --git a/crates/whitaker_clones_core/src/run0/tests.rs b/crates/whitaker_clones_core/src/run0/tests.rs index 321824933..aae0d3a4e 100644 --- a/crates/whitaker_clones_core/src/run0/tests.rs +++ b/crates/whitaker_clones_core/src/run0/tests.rs @@ -157,6 +157,7 @@ fn multi_line_region_tracks_trailing_newline() { ); } +/// Verify that run0 emits primary and related SARIF locations. #[test] fn emit_run0_uses_primary_and_related_locations() { let fragments = vec![ diff --git a/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs b/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs index cb0ce7010..2e6c888b7 100644 --- a/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs +++ b/crates/whitaker_clones_core/tests/run0_sarif_behaviour.rs @@ -304,6 +304,7 @@ fn then_primary_region_is(world: &Run0World, region: String) { }); } +/// Check that the emitted primary location uses the expected file URI. #[then("the primary file is {file_uri}")] fn then_primary_file_is(world: &Run0World, file_uri: String) { with_results(world, |results| { diff --git a/crates/whitaker_sarif/src/builders/location_builder.rs b/crates/whitaker_sarif/src/builders/location_builder.rs index ff5b161a5..2c14ee6f7 100644 --- a/crates/whitaker_sarif/src/builders/location_builder.rs +++ b/crates/whitaker_sarif/src/builders/location_builder.rs @@ -252,6 +252,7 @@ mod tests { } } + /// Build a location with only its required artefact URI. #[test] fn location_builder_minimal() { let loc = LocationBuilder::new("src/main.rs").build(); @@ -274,6 +275,7 @@ mod tests { } } + /// Build a location with a URI base identifier. #[test] fn location_builder_with_base_id() { let loc = LocationBuilder::new("src/lib.rs") diff --git a/crates/whitaker_sarif/src/builders/log_builder.rs b/crates/whitaker_sarif/src/builders/log_builder.rs index 53a25b8c8..91696db58 100644 --- a/crates/whitaker_sarif/src/builders/log_builder.rs +++ b/crates/whitaker_sarif/src/builders/log_builder.rs @@ -98,6 +98,7 @@ mod tests { assert!(log.runs.is_empty()); } + /// Build a log containing a run. #[test] fn builds_log_with_run() { let run = Run { diff --git a/crates/whitaker_sarif/src/model/location.rs b/crates/whitaker_sarif/src/model/location.rs index ccb0e81ff..895d34bc1 100644 --- a/crates/whitaker_sarif/src/model/location.rs +++ b/crates/whitaker_sarif/src/model/location.rs @@ -170,6 +170,7 @@ mod tests { }); } + /// Check that a location survives JSON serialization and deserialization. #[test] fn location_round_trip() { let loc = Location { @@ -191,6 +192,7 @@ mod tests { assert_json_round_trip(&loc); } + /// Check that a related location survives a JSON round trip. #[test] fn related_location_round_trip() { let rl = RelatedLocation { diff --git a/crates/whitaker_sarif/src/model/run.rs b/crates/whitaker_sarif/src/model/run.rs index 532b07ddd..0aeb2c2c4 100644 --- a/crates/whitaker_sarif/src/model/run.rs +++ b/crates/whitaker_sarif/src/model/run.rs @@ -156,6 +156,7 @@ mod tests { use crate::model::location::ArtefactLocation; use crate::test_support::{assert_json_round_trip, assert_serialized_json}; + /// Check that a run survives JSON serialization and deserialization. #[test] fn run_round_trip() { let run = Run { @@ -177,6 +178,7 @@ mod tests { assert_json_round_trip(&run); } + /// Check that empty run collections are omitted from serialized JSON. #[test] fn empty_collections_omitted_from_run() { let run = Run { @@ -209,6 +211,7 @@ mod tests { }); } + /// Check that an artefact survives JSON serialization and deserialization. #[test] fn artefact_round_trip() { let artefact = Artefact { diff --git a/crates/whitaker_sarif/tests/api_contract.rs b/crates/whitaker_sarif/tests/api_contract.rs index 73c9b8ed9..3737b1d46 100644 --- a/crates/whitaker_sarif/tests/api_contract.rs +++ b/crates/whitaker_sarif/tests/api_contract.rs @@ -1,5 +1,6 @@ //! Compile-time contracts for the externally exposed SARIF spelling changes. +/// Verify that renamed SARIF APIs compile and legacy names remain removed. #[test] fn renamed_sarif_api_is_public_and_legacy_names_stay_removed() { let cases = trybuild::TestCases::new(); diff --git a/crates/whitaker_sarif/tests/ui/legacy_sarif_api.rs b/crates/whitaker_sarif/tests/ui/legacy_sarif_api.rs index cd2ca3b9a..d31b826c8 100644 --- a/crates/whitaker_sarif/tests/ui/legacy_sarif_api.rs +++ b/crates/whitaker_sarif/tests/ui/legacy_sarif_api.rs @@ -2,6 +2,7 @@ use whitaker_sarif::{Artifact, ArtifactLocation, RunBuilder}; +/// Exercise removed SARIF API names in the compile-fail fixture. fn main() { let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artifact { location: ArtifactLocation { diff --git a/crates/whitaker_sarif/tests/ui/legacy_sarif_api.stderr b/crates/whitaker_sarif/tests/ui/legacy_sarif_api.stderr index 3c749d700..280a4107c 100644 --- a/crates/whitaker_sarif/tests/ui/legacy_sarif_api.stderr +++ b/crates/whitaker_sarif/tests/ui/legacy_sarif_api.stderr @@ -18,13 +18,13 @@ help: a similar name exists in the module | error[E0599]: no method named `with_artifact` found for struct `RunBuilder` in the current scope - --> tests/ui/legacy_sarif_api.rs:6:50 + --> tests/ui/legacy_sarif_api.rs:7:50 | -6 | let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artifact { +7 | let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artifact { | -------------------------------------^^^^^^^^^^^^^ | help: there is a method `with_artefact` with a similar name | -6 - let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artifact { -6 + let _ = RunBuilder::new("whitaker", "0.3.0").with_artefact(Artifact { +7 - let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artifact { +7 + let _ = RunBuilder::new("whitaker", "0.3.0").with_artefact(Artifact { | diff --git a/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.rs b/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.rs index 889004e6a..e7ae6f5d2 100644 --- a/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.rs +++ b/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.rs @@ -2,6 +2,7 @@ use whitaker_sarif::{Artefact, ArtefactLocation, PhysicalLocation, RunBuilder}; +/// Exercise removed SARIF field names in the compile-fail fixture. fn main() { let _location = PhysicalLocation { artifact_location: ArtefactLocation { diff --git a/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.stderr b/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.stderr index 80eb8bb02..96c7eaa1a 100644 --- a/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.stderr +++ b/crates/whitaker_sarif/tests/ui/legacy_sarif_fields.stderr @@ -1,35 +1,35 @@ error[E0560]: struct `PhysicalLocation` has no field named `artifact_location` - --> tests/ui/legacy_sarif_fields.rs:7:9 + --> tests/ui/legacy_sarif_fields.rs:8:9 | -7 | artifact_location: ArtefactLocation { +8 | artifact_location: ArtefactLocation { | ^^^^^^^^^^^^^^^^^ unknown field | help: a field with a similar name exists | -7 - artifact_location: ArtefactLocation { -7 + artefact_location: ArtefactLocation { +8 - artifact_location: ArtefactLocation { +8 + artefact_location: ArtefactLocation { | error[E0609]: no field `artifacts` on type `Run` - --> tests/ui/legacy_sarif_fields.rs:14:17 + --> tests/ui/legacy_sarif_fields.rs:15:17 | -14 | let _ = run.artifacts; +15 | let _ = run.artifacts; | ^^^^^^^^^ unknown field | help: a field with a similar name exists | -14 - let _ = run.artifacts; -14 + let _ = run.artefacts; +15 - let _ = run.artifacts; +15 + let _ = run.artefacts; | error[E0599]: no method named `with_artifact` found for struct `RunBuilder` in the current scope - --> tests/ui/legacy_sarif_fields.rs:15:50 + --> tests/ui/legacy_sarif_fields.rs:16:50 | -15 | let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artefact { +16 | let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artefact { | -------------------------------------^^^^^^^^^^^^^ | help: there is a method `with_artefact` with a similar name | -15 - let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artefact { -15 + let _ = RunBuilder::new("whitaker", "0.3.0").with_artefact(Artefact { +16 - let _ = RunBuilder::new("whitaker", "0.3.0").with_artifact(Artefact { +16 + let _ = RunBuilder::new("whitaker", "0.3.0").with_artefact(Artefact { | diff --git a/crates/whitaker_sarif/tests/ui/pass_renamed_sarif_api.rs b/crates/whitaker_sarif/tests/ui/pass_renamed_sarif_api.rs index 905524400..e89581463 100644 --- a/crates/whitaker_sarif/tests/ui/pass_renamed_sarif_api.rs +++ b/crates/whitaker_sarif/tests/ui/pass_renamed_sarif_api.rs @@ -2,6 +2,7 @@ use whitaker_sarif::{Artefact, ArtefactLocation, PhysicalLocation, RunBuilder}; +/// Exercise the renamed SARIF API in the compile-pass fixture. fn main() { let artefact = Artefact { location: ArtefactLocation { diff --git a/docs/users-guide.md b/docs/users-guide.md index 8ddf4e0ce..64a0b8244 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -112,6 +112,18 @@ environment-variable workaround. [Pinning the lint suite](#pinning-the-lint-suite) - `--no-source-fallback` โ€” Fail rather than build from source when a published artefact is absent. See [Refusing a source build](#refusing-a-source-build) +- `-v`, `--verbose` โ€” Include debug diagnostics, including bounded reasons why + a `PATH` candidate was skipped. The candidate path is not printed. +- `-q`, `--quiet` โ€” Suppress progress output and show only errors. + +Installer diagnostics are written to standard error without ANSI formatting. +The default verbosity shows warnings and errors; `-v` enables debug events, and +`-q` limits tracing output to errors. + +The installer scans `PATH` entries in order and uses the first regular +executable candidate for `dylint-link`. It skips missing candidates and entries +that cannot be represented as UTF-8 or opened, then continues searching later +entries. ### Adding Whitaker to a project diff --git a/installer/src/artefact/extraction.rs b/installer/src/artefact/extraction.rs index 0c7e3af3a..c07e50bb3 100644 --- a/installer/src/artefact/extraction.rs +++ b/installer/src/artefact/extraction.rs @@ -117,6 +117,7 @@ mod tests { use rstest::rstest; use std::path::PathBuf; + /// Extract a real archive and verify that its file is written. #[test] fn extract_real_archive() { // Create a temp archive with a single file, then extract it. @@ -173,6 +174,7 @@ mod tests { assert!(matches!(result, Err(ExtractionError::PathTraversal { .. }))); } + /// Reject an archive that contains no entries. #[test] fn extract_empty_archive() { let temp_dir = tempfile::tempdir().expect("temp dir"); diff --git a/installer/src/artefact/schema_version.rs b/installer/src/artefact/schema_version.rs index 2631d8501..69640b7cd 100644 --- a/installer/src/artefact/schema_version.rs +++ b/installer/src/artefact/schema_version.rs @@ -96,6 +96,7 @@ mod tests { assert_eq!(v.expect("checked above").as_u32(), 1); } + /// Reject schema version zero as unsupported. #[test] fn rejects_version_zero() { let result = SchemaVersion::try_from(0_u32); diff --git a/installer/src/artefact/sha256_digest.rs b/installer/src/artefact/sha256_digest.rs index 1332219f4..2109e580b 100644 --- a/installer/src/artefact/sha256_digest.rs +++ b/installer/src/artefact/sha256_digest.rs @@ -129,6 +129,7 @@ mod tests { use super::*; use rstest::{fixture, rstest}; + /// Create a valid hexadecimal digest for validation tests. #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn valid_digest() -> String { diff --git a/installer/src/deps/mod.rs b/installer/src/deps/mod.rs index 69c692a47..ce7235fc9 100644 --- a/installer/src/deps/mod.rs +++ b/installer/src/deps/mod.rs @@ -567,6 +567,7 @@ mod path_scan_tests { ); } + /// Keep path-scan failure category labels stable and bounded. #[test] fn path_scan_failure_categories_are_stable_and_bounded() { assert_eq!( diff --git a/installer/src/output.rs b/installer/src/output.rs index d738919ca..e0eac400e 100644 --- a/installer/src/output.rs +++ b/installer/src/output.rs @@ -221,6 +221,7 @@ mod tests { assert!(display.contains("PowerShell")); } + /// Check that success messages use singular or plural labels as needed. #[rstest] #[case::singular(1, "1 lint library")] #[case::plural(5, "5 lint libraries")] diff --git a/installer/src/tracing_config.rs b/installer/src/tracing_config.rs index 3c61b7041..3a0c98565 100644 --- a/installer/src/tracing_config.rs +++ b/installer/src/tracing_config.rs @@ -43,10 +43,98 @@ mod tests { use std::io::{Read, Seek}; + use clap::Parser; use rstest::rstest; + use whitaker_installer::cli::Cli; use super::*; + /// Verify CLI flags filter the real PATH-scan diagnostic in a child process. + #[cfg(unix)] + #[test] + fn cli_flags_filter_path_scan_diagnostics_on_stderr() { + const PROBE_ENV: &str = "WHITAKER_TRACING_PATH_SCAN_PROBE"; + if let Some(mode) = std::env::var_os(PROBE_ENV) { + let arguments = match mode.to_str() { + Some("verbose") => vec!["whitaker-installer", "-v"], + Some("quiet") => vec!["whitaker-installer", "-q"], + _ => vec!["whitaker-installer"], + }; + let cli = Cli::try_parse_from(arguments) + .expect("probe arguments must match the installer CLI"); + configure_tracing(cli.install_args()); + let _ = whitaker_installer::deps::check_dylint_tools( + &whitaker_installer::deps::SystemCommandExecutor, + ); + return; + } + + use camino::Utf8Path; + use cap_std::{ambient_authority, fs_utf8::Dir}; + + let fixture = tempfile::tempdir().expect("PATH fixture directory must be created"); + let invalid_entry = fixture.path().join("not-a-directory"); + let fixture_path = + Utf8Path::from_path(fixture.path()).expect("temporary directory path must be UTF-8"); + let fixture_dir = Dir::open_ambient_dir(fixture_path, ambient_authority()) + .expect("temporary directory must open as a capability"); + fixture_dir + .write("not-a-directory", b"file entry") + .expect("invalid PATH entry must be created"); + fixture_dir + .create_dir("bin") + .expect("binary directory must be created"); + let binary_directory = fixture.path().join("bin"); + let fake_linker = binary_directory.join("dylint-link"); + whitaker_installer::test_utils::dependency_binary_helpers::write_fake_binary( + &fake_linker, + true, + ) + .expect("fake linker must be executable"); + + let original_path = std::env::var_os("PATH").unwrap_or_default(); + let original_paths = std::env::split_paths(&original_path); + let path_entries = std::iter::once(invalid_entry.clone()) + .chain(std::iter::once(binary_directory.clone())) + .chain(original_paths); + let path = std::env::join_paths(path_entries).expect("fixture PATH must be valid"); + + for mode in ["default", "verbose", "quiet"] { + let output = std::process::Command::new( + std::env::current_exe().expect("test executable must be discoverable"), + ) + .args([ + "--exact", + "tracing_config::tests::cli_flags_filter_path_scan_diagnostics_on_stderr", + "--nocapture", + ]) + .env(PROBE_ENV, mode) + .env("PATH", &path) + .output() + .expect("isolated CLI tracing probe must run"); + assert!( + output.status.success(), + "{mode} tracing probe must succeed: {output:?}" + ); + let stderr = String::from_utf8(output.stderr).expect("stderr must be UTF-8"); + let stdout = String::from_utf8(output.stdout).expect("stdout must be UTF-8"); + let has_path_diagnostic = stderr.contains("skipping PATH scan candidate"); + assert_eq!( + has_path_diagnostic, + mode == "verbose", + "{mode} output: {stderr}" + ); + if has_path_diagnostic { + assert!(stderr.contains("binary_name=\"dylint-link\"")); + assert!(stderr.contains("failure_category=\"directory_open\"")); + assert!(!stderr.contains(invalid_entry.to_string_lossy().as_ref())); + assert!(!stderr.contains(binary_directory.to_string_lossy().as_ref())); + } + assert!(!stdout.contains("skipping PATH scan candidate")); + assert!(!stderr.contains('\u{1b}'), "stderr must omit ANSI escapes"); + } + } + /// Verify CLI initialization sends diagnostics to stderr in its own process. #[test] fn tracing_writes_to_stderr() { diff --git a/src/config.rs b/src/config.rs index d223dfdcd..390c75948 100644 --- a/src/config.rs +++ b/src/config.rs @@ -132,6 +132,7 @@ mod tests { assert!(config.locale().is_none()); } + /// Check that TOML configuration overrides are deserialized. #[rstest] fn deserializes_overrides_from_toml() { let source = "[module_max_lines]\nmax_lines = 120\n"; @@ -143,6 +144,7 @@ mod tests { assert_eq!(config.module_max_lines.max_lines, 120); } + /// Check that a locale override is deserialized. #[rstest] fn deserializes_locale_override() { let source = "locale = \"cy\"\n"; @@ -163,6 +165,7 @@ mod tests { assert!(config.locale().is_none()); } + /// Check that invalid configuration values return a deserialization error. #[rstest] fn propagates_deserialization_failures() { let source = "[module_max_lines]\nmax_lines = \"a lot\"\n"; diff --git a/tests/locale_resolution.rs b/tests/locale_resolution.rs index b06c12cfc..cf6131c84 100644 --- a/tests/locale_resolution.rs +++ b/tests/locale_resolution.rs @@ -18,6 +18,7 @@ struct LocaleWorld { resolution: RefCell>, } +/// Create the default locale-resolution test world. #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn world() -> LocaleWorld { @@ -102,6 +103,7 @@ fn assert_source(world: &LocaleWorld, source: StepSource) { assert_eq!(resolution.source(), source.into_inner()); } +/// Check that locale resolution matches the scenario value. #[then("the resolved locale is {value}")] fn assert_locale(world: &LocaleWorld, value: StepLocale) { let resolution = resolved(world); diff --git a/tests/ui_harness.rs b/tests/ui_harness.rs index b442a3f71..27d210828 100644 --- a/tests/ui_harness.rs +++ b/tests/ui_harness.rs @@ -57,6 +57,7 @@ impl Default for HarnessWorld { } } +/// Create the default UI-harness test world. #[whitaker_test_macros::allow_fixture_expansion_lints] #[fixture] fn harness_world() -> HarnessWorld { diff --git a/typos.local.toml b/typos.local.toml index d0020aad1..3c27a55ef 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -22,9 +22,11 @@ ignore = [ "(?m)^ !json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", "(?m)^ json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", "(?m)^ json\\.contains\\(\"\\\\\"artifactLocation\\\\\"\"\\),$", - # Keep schema keys exempt only when they appear as quoted wire values; keep - # the removed field spelling only in its compile-fail regression fixture. - "(?m)^.*\\\"(?:artifactLocation|artifacts)\\\".*$", + # Keep schema keys exempt only in JSON fields, lookups and index expressions. + # Keep the removed field spelling only in its compile-fail regression fixture. + '"(?:artifactLocation|artifacts)"[ \t]*:', + '\["(?:artifactLocation|artifacts)"\]', + '\w+\.get\("(?:artifactLocation|artifacts)"\)', "(?m)^.*run\\.artifacts;.*$", "(?m)^ \"\\-\\-artifact\\-server\\-path\",$", "(?m)^ let Message::CompilerArtifact\\(artefact\\) = message else \\{$", diff --git a/typos.toml b/typos.toml index a7666e440..db47e92d0 100644 --- a/typos.toml +++ b/typos.toml @@ -34,6 +34,7 @@ extend-exclude = [ [default] locale = "en-gb" extend-ignore-re = [ + "\"(?:artifactLocation|artifacts)\"[ \\t]*:", "(?m)^ !json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", "(?m)^ json\\.contains\\(\"\\\\\"artifactLocation\\\\\"\"\\),$", "(?m)^ json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", @@ -77,7 +78,6 @@ extend-ignore-re = [ "(?m)^## Seachain `unwrap_or_else` a tha aโ€™ clisgeadh\\.$", "(?m)^## Tha `expect` toirmisgte taobh a\\-muigh deuchainnean\\.$", "(?m)^#\\. Air a shealltainn ann an breithneachaidhean nuair a tha e aโ€™ toirt iomradh air am buadh roimhe\\.$", - "(?m)^.*\\\"(?:artifactLocation|artifacts)\\\".*$", "(?m)^.*run\\.artifacts;.*$", "(?m)^\\- Lexical segment matching can mis\\-handle renamed imports, glob imports, or$", "(?m)^\\- `\\-\\-artifact\\-server\\-path `: export uploaded artefacts to a host$", @@ -94,6 +94,7 @@ extend-ignore-re = [ "GitHub\\s+Flavored\\s+Markdown\\b(?:\\s\\(GFM\\))?", "HashiCorp", "How to organise your Rust tests", + "\\[\"(?:artifactLocation|artifacts)\"\\]", "\\b(?:background|theme)_color\\b", "\\b(?:carousel|dropdown|footer|indicator|items|navbar|toast)-center\\b", "\\b(?:normalise_locale|rasterise_signal|normalise_settings|Artifact|ArtifactLocation|with_artifact|artifact_location)\\b", @@ -102,6 +103,7 @@ extend-ignore-re = [ "\\bcolor-(?:mix|contrast)\\b", "\\bflavor = ", "\\brust-analyzer\\b", + "\\w+\\.get\\(\"(?:artifactLocation|artifacts)\"\\)", "\\{COLOR\\}", "\\{color\\}", "`[^`\\n]+`", From 4fef0a4eb0d4a4fbc1a32a8832700b8e2028bfb8 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 15 Oct 2026 04:21:28 +0200 Subject: [PATCH 18/32] Simplify tracing test structure Split the child-process tracing probe from its parent orchestration and keep assertions direct. Document why the helper stays test-private and why the child boundary isolates tracing and PATH state. --- docs/developers-guide.md | 5 ++++ installer/src/tracing_config.rs | 52 ++++++++++++++++++--------------- 2 files changed, 34 insertions(+), 23 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index ad0c8068b..479c4da05 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -278,6 +278,11 @@ later assertion steps to return without inspecting missing output, and is reported through `rstest_bdd::skip!`. Keep these skip rules in the shared setup helpers so all scenario variants retain the same isolation and lifecycle. +The PATH-scan tracing test in `installer/src/tracing_config.rs` re-executes its +test binary in a child process to exercise the real CLI flags and diagnostic. +Its probe helper belongs only to that test; the child boundary keeps tracing +subscriber setup and the test `PATH` isolated from the parent process. + ### Test profiles By default, `make test` excludes slow installer integration tests diff --git a/installer/src/tracing_config.rs b/installer/src/tracing_config.rs index 3a0c98565..5eb59d335 100644 --- a/installer/src/tracing_config.rs +++ b/installer/src/tracing_config.rs @@ -49,23 +49,14 @@ mod tests { use super::*; + const PATH_SCAN_PROBE_ENV: &str = "WHITAKER_TRACING_PATH_SCAN_PROBE"; + /// Verify CLI flags filter the real PATH-scan diagnostic in a child process. #[cfg(unix)] #[test] fn cli_flags_filter_path_scan_diagnostics_on_stderr() { - const PROBE_ENV: &str = "WHITAKER_TRACING_PATH_SCAN_PROBE"; - if let Some(mode) = std::env::var_os(PROBE_ENV) { - let arguments = match mode.to_str() { - Some("verbose") => vec!["whitaker-installer", "-v"], - Some("quiet") => vec!["whitaker-installer", "-q"], - _ => vec!["whitaker-installer"], - }; - let cli = Cli::try_parse_from(arguments) - .expect("probe arguments must match the installer CLI"); - configure_tracing(cli.install_args()); - let _ = whitaker_installer::deps::check_dylint_tools( - &whitaker_installer::deps::SystemCommandExecutor, - ); + if std::env::var_os(PATH_SCAN_PROBE_ENV).is_some() { + run_path_scan_probe(); return; } @@ -108,7 +99,7 @@ mod tests { "tracing_config::tests::cli_flags_filter_path_scan_diagnostics_on_stderr", "--nocapture", ]) - .env(PROBE_ENV, mode) + .env(PATH_SCAN_PROBE_ENV, mode) .env("PATH", &path) .output() .expect("isolated CLI tracing probe must run"); @@ -119,22 +110,37 @@ mod tests { let stderr = String::from_utf8(output.stderr).expect("stderr must be UTF-8"); let stdout = String::from_utf8(output.stdout).expect("stdout must be UTF-8"); let has_path_diagnostic = stderr.contains("skipping PATH scan candidate"); + let is_verbose = mode == "verbose"; + assert_eq!(has_path_diagnostic, is_verbose); + assert_eq!(stderr.contains("binary_name=\"dylint-link\""), is_verbose); assert_eq!( - has_path_diagnostic, - mode == "verbose", - "{mode} output: {stderr}" + stderr.contains("failure_category=\"directory_open\""), + is_verbose ); - if has_path_diagnostic { - assert!(stderr.contains("binary_name=\"dylint-link\"")); - assert!(stderr.contains("failure_category=\"directory_open\"")); - assert!(!stderr.contains(invalid_entry.to_string_lossy().as_ref())); - assert!(!stderr.contains(binary_directory.to_string_lossy().as_ref())); - } + assert!(!stderr.contains(invalid_entry.to_string_lossy().as_ref())); + assert!(!stderr.contains(binary_directory.to_string_lossy().as_ref())); assert!(!stdout.contains("skipping PATH scan candidate")); assert!(!stderr.contains('\u{1b}'), "stderr must omit ANSI escapes"); } } + /// Run the child-only probe that exercises the production PATH scan event. + #[cfg(unix)] + fn run_path_scan_probe() { + let mode = std::env::var(PATH_SCAN_PROBE_ENV); + let flags: &[&str] = match mode.as_deref() { + Ok("verbose") => &["-v"], + Ok("quiet") => &["-q"], + _ => &[], + }; + let arguments = std::iter::once("whitaker-installer").chain(flags.iter().copied()); + let cli = Cli::try_parse_from(arguments).expect("probe arguments must match the CLI"); + configure_tracing(cli.install_args()); + let _ = whitaker_installer::deps::check_dylint_tools( + &whitaker_installer::deps::SystemCommandExecutor, + ); + } + /// Verify CLI initialization sends diagnostics to stderr in its own process. #[test] fn tracing_writes_to_stderr() { From a1fcacc4935c6eaabc59890ac4689a6cd28fbbb1 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 15 Oct 2026 05:55:59 +0200 Subject: [PATCH 19/32] Refresh lockfile and gate Unix tracing probe Update the workspace lock data for the rebased manifests and restrict the CLI PATH-scan probe to Unix, where its process and PATH contract applies. --- Cargo.lock | 69 +++++++++++++++++++++++++++++++++ installer/src/tracing_config.rs | 3 ++ 2 files changed, 72 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index ad798b4b8..8e45e2244 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -196,6 +196,7 @@ dependencies = [ "serde", "tempfile", "toml 1.1.7+spec-1.1.0", + "trybuild", "whitaker", "whitaker-common", "whitaker_test_macros", @@ -1724,6 +1725,7 @@ dependencies = [ "tempfile", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] @@ -1817,6 +1819,7 @@ dependencies = [ "toml 1.1.7+spec-1.1.0", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] @@ -1843,6 +1846,15 @@ dependencies = [ "whitaker_test_macros", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2804,6 +2816,15 @@ dependencies = [ "digest", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" version = "2.0.1" @@ -3007,6 +3028,7 @@ dependencies = [ "serde", "whitaker", "whitaker-common", + "whitaker_test_macros", ] [[package]] @@ -3079,6 +3101,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -3201,6 +3232,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "nu-ansi-term", + "sharded-slab", + "smallvec", + "thread_local", + "tracing-core", + "tracing-log", ] [[package]] @@ -3422,6 +3479,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "value-bag" version = "1.13.0" @@ -3554,6 +3617,7 @@ dependencies = [ "toml 1.1.7+spec-1.1.0", "whitaker-common", "whitaker-installer", + "whitaker_test_macros", ] [[package]] @@ -3575,6 +3639,7 @@ dependencies = [ "rstest-bdd-macros", "tempfile", "thiserror 2.0.21", + "trybuild", "unic-langid", "whitaker_test_macros", ] @@ -3593,6 +3658,7 @@ dependencies = [ "log", "mockall", "parking_lot", + "proptest", "rstest", "rstest-bdd", "rstest-bdd-macros", @@ -3605,6 +3671,7 @@ dependencies = [ "thiserror 2.0.21", "toml 1.1.7+spec-1.1.0", "tracing", + "tracing-subscriber", "trybuild", "ureq", "wait-timeout", @@ -3645,6 +3712,7 @@ name = "whitaker_sarif" version = "0.2.9" dependencies = [ "camino", + "proptest", "rstest", "rstest-bdd", "rstest-bdd-macros", @@ -3652,6 +3720,7 @@ dependencies = [ "serde_json", "tempfile", "thiserror 2.0.21", + "trybuild", "whitaker_test_macros", ] diff --git a/installer/src/tracing_config.rs b/installer/src/tracing_config.rs index 5eb59d335..911795d64 100644 --- a/installer/src/tracing_config.rs +++ b/installer/src/tracing_config.rs @@ -43,12 +43,15 @@ mod tests { use std::io::{Read, Seek}; + #[cfg(unix)] use clap::Parser; use rstest::rstest; + #[cfg(unix)] use whitaker_installer::cli::Cli; use super::*; + #[cfg(unix)] const PATH_SCAN_PROBE_ENV: &str = "WHITAKER_TRACING_PATH_SCAN_PROBE"; /// Verify CLI flags filter the real PATH-scan diagnostic in a child process. From 233f60f92b620344daf767333f00a9c1048197a3 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 15 Oct 2026 06:34:29 +0200 Subject: [PATCH 20/32] Test installer tracing through the real CLI (#363) Exercise default, verbose, and quiet tracing through `whitaker-installer` itself. Keep PATH, home directories, dependencies, and the expected rustup failure inside a temporary child environment while preserving Cargo's runtime library path for dynamically linked test builds. --- installer/tests/tracing_cli.rs | 208 +++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 installer/tests/tracing_cli.rs diff --git a/installer/tests/tracing_cli.rs b/installer/tests/tracing_cli.rs new file mode 100644 index 000000000..952c853e2 --- /dev/null +++ b/installer/tests/tracing_cli.rs @@ -0,0 +1,208 @@ +//! Verify the installer binary wires CLI verbosity into bounded PATH tracing. + +#![cfg(unix)] + +use std::{ + error::Error, + ffi::OsString, + fs, io, + os::unix::fs::PermissionsExt, + path::{Path, PathBuf}, + process::{Command, Output}, +}; + +/// Keep an isolated workspace, PATH, and home directories alive for CLI runs. +struct TracingFixture { + root: tempfile::TempDir, + workspace: PathBuf, + environment: Vec<(OsString, OsString)>, +} + +impl TracingFixture { + /// Create temporary binaries and a minimal current-directory workspace. + /// + /// # Errors + /// + /// Returns an error if the fixture cannot be created or dependency + /// versions cannot be read from the embedded manifest. + fn new() -> Result> { + let root = tempfile::tempdir()?; + let workspace = root.path().join("workspace"); + let binary_directory = root.path().join("bin"); + let invalid_path_entry = root.path().join("not-a-directory"); + fs::create_dir(&workspace)?; + fs::create_dir(&binary_directory)?; + fs::write(&invalid_path_entry, b"PATH entry is a file")?; + fs::write( + workspace.join("Cargo.toml"), + "[package]\nname = \"whitaker\"\nversion = \"0.0.0\"\nedition = \"2024\"\n", + )?; + let cargo_version = dependency_version("cargo-dylint")?; + let link_version = dependency_version("dylint-link")?; + write_executable( + &binary_directory.join("cargo"), + &fake_cargo_script(cargo_version, link_version), + )?; + write_executable(&binary_directory.join("dylint-link"), "#!/bin/sh\nexit 0\n")?; + write_executable( + &binary_directory.join("rustup"), + "#!/bin/sh\necho 'controlled rustup failure' >&2\nexit 1\n", + )?; + + let data_home = root.path().join("data"); + let config_home = root.path().join("config"); + let cache_home = root.path().join("cache"); + let rustup_home = root.path().join("rustup"); + let cargo_home = root.path().join("cargo-home"); + for directory in [ + &data_home, + &config_home, + &cache_home, + &rustup_home, + &cargo_home, + ] { + fs::create_dir(directory)?; + } + + let path = std::env::join_paths([&invalid_path_entry, &binary_directory])?; + let mut environment = vec![ + (OsString::from("PATH"), path), + (OsString::from("HOME"), root.path().as_os_str().to_owned()), + (OsString::from("XDG_DATA_HOME"), data_home.into_os_string()), + ( + OsString::from("XDG_CONFIG_HOME"), + config_home.into_os_string(), + ), + ( + OsString::from("XDG_CACHE_HOME"), + cache_home.into_os_string(), + ), + (OsString::from("RUSTUP_HOME"), rustup_home.into_os_string()), + (OsString::from("CARGO_HOME"), cargo_home.into_os_string()), + ]; + if let Some(ld_library_path) = std::env::var_os("LD_LIBRARY_PATH") { + environment.push((OsString::from("LD_LIBRARY_PATH"), ld_library_path)); + } + + Ok(Self { + root, + workspace, + environment, + }) + } +} + +/// Exercise the real binary's verbosity parsing and tracing initialization. +/// +/// # Errors +/// +/// Returns an error if fixture construction or a child process fails. +#[test] +fn main_filters_path_scan_diagnostics_by_cli_verbosity() -> Result<(), Box> { + let fixture = TracingFixture::new()?; + for (mode, arguments, is_verbose) in [ + ("default", &[][..], false), + ("verbose", &["-v"][..], true), + ("quiet", &["-q"][..], false), + ] { + let output = run_cli_mode(&fixture, arguments)?; + assert_cli_output(&output, &fixture, mode, is_verbose); + } + Ok(()) +} + +/// Run the installer with an isolated child environment and selected flags. +/// +/// # Errors +/// +/// Returns an I/O error if the installer process cannot be started or read. +fn run_cli_mode(fixture: &TracingFixture, arguments: &[&str]) -> io::Result { + Command::new(env!("CARGO_BIN_EXE_whitaker-installer")) + .args(["--toolchain", "nightly-test"]) + .args(arguments) + .current_dir(&fixture.workspace) + .env_clear() + .envs(fixture.environment.iter().cloned()) + .output() +} + +/// Check the controlled failure and verbosity-filtered diagnostic contract. +fn assert_cli_output(output: &Output, fixture: &TracingFixture, mode: &str, is_verbose: bool) { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let diagnostic = "skipping PATH scan candidate after lookup failure"; + + assert_eq!( + output.status.code(), + Some(1), + "{mode} mode must reach the fake rustup failure; stderr: {stderr}" + ); + assert!( + stderr.contains("toolchain nightly-test installation failed: controlled rustup failure"), + "{mode} mode must reach the controlled failure after dependency checks; stderr: {stderr}" + ); + assert_eq!( + stderr.contains(diagnostic), + is_verbose, + "{mode} mode stderr: {stderr}" + ); + assert_eq!( + stderr.contains("binary_name=\"dylint-link\""), + is_verbose, + "{mode} mode stderr: {stderr}" + ); + assert_eq!( + stderr.contains("failure_category=\"directory_open\""), + is_verbose, + "{mode} mode stderr: {stderr}" + ); + assert!(!stdout.contains(diagnostic), "diagnostic must be on stderr"); + let fixture_path = fixture.root.path().to_string_lossy(); + assert!(!stdout.contains(fixture_path.as_ref())); + assert!(!stderr.contains(fixture_path.as_ref())); + assert!(!stdout.contains('\u{1b}')); + assert!(!stderr.contains('\u{1b}')); +} + +/// Build the fake Cargo responses from the versions required by the installer. +fn fake_cargo_script(cargo_version: &str, link_version: &str) -> String { + format!( + concat!( + "#!/bin/sh\n", + "case \"$1 $2\" in\n", + " \"dylint --version\") echo 'cargo-dylint {}' ;;\n", + " \"install --list\") printf '%s\\n' 'cargo-dylint v{}:' ", + "'dylint-link v{}:' ;;\n", + " *) echo 'unexpected fake cargo invocation' >&2; exit 97 ;;\n", + "esac\n", + ), + cargo_version, cargo_version, link_version, + ) +} + +/// Return a dependency binary's version from the embedded manifest. +/// +/// # Errors +/// +/// Returns an error if the manifest cannot be parsed or omits the package. +fn dependency_version(package: &str) -> Result<&'static str, Box> { + let dependency = whitaker_installer::dependency_binaries::find_dependency_binary(package)? + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("embedded dependency manifest must include {package}"), + ) + })?; + Ok(dependency.version()) +} + +/// Write a Unix script that can be found and run through the isolated PATH. +/// +/// # Errors +/// +/// Returns a filesystem error if the script or its executable permissions +/// cannot be written. +fn write_executable(path: &Path, contents: &str) -> io::Result<()> { + fs::write(path, contents)?; + fs::set_permissions(path, fs::Permissions::from_mode(0o755)) +} From 98ffa18db4892e26c22c59b44ede5473c0dddab1 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 15 Oct 2026 06:58:36 +0200 Subject: [PATCH 21/32] Clarify compile-contract test inventory Document the six discovered trybuild tests and distinguish the three `ui` binary names from their individual test function names. Keep the nextest timeout rationale grounded in the current discovery set. --- docs/developers-guide.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 479c4da05..233139294 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -4224,16 +4224,19 @@ failure and `tests/nextest_ui_filter.rs` requires the key. It discovers the compile-contract tests rather than listing them. A test that drives `trybuild` invokes a nested cargo build, so it costs what a build costs -and the base per-test allowance is the wrong order of magnitude for it. There -are two here, and both are in a binary called `ui`, which is why the set is -found from the `trybuild::TestCases` calls in the tree and each test is -required by name: `whitaker-installer`'s test sits in a binary named `ui` but -is not itself named `ui`, so the `(binary(ui) & test(=ui))` clause never -reached it. It ran under the base 300-second allowance, passed at 235 seconds, -and timed out at 300.216 seconds on the Windows lane, cancelling the run. A -handwritten list would have had the same hole, and a discovery that stops -recognizing the call would sweep a smaller set silently, so finding nothing is -a failure rather than a pass. +and the base per-test allowance is the wrong order of magnitude for it. The +current discovery finds six tests across `common/tests/public_api_contract.rs`, +`crates/bumpy_road_function/tests/api_contract.rs`, +`crates/whitaker_sarif/tests/api_contract.rs`, `installer/tests/ui.rs`, +`crates/whitaker_test_macros/tests/ui.rs`, and +`crates/rstest_helper_should_be_fixture/tests/ui.rs`. Three of those binaries +are named `ui`, but their test functions have different names. For example, +`whitaker-installer`'s test is in a binary named `ui` but is not itself named +`ui`, so the `(binary(ui) & test(=ui))` clause never reached it. It ran under +the base 300-second allowance, passed at 235 seconds, and timed out at 300.216 +seconds on the Windows lane, cancelling the run. A handwritten list would have +had the same hole, and a discovery that stops recognizing the call would sweep +a smaller set silently, so finding nothing is a failure rather than a pass. It drives the lane discovery with workflow documents rather than only with the files. `_declared_jobs` takes parsed documents and reads the repository's own From ac625ae214eb9eda5c4b6d4bd1a114cb04deb253 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 15 Oct 2026 07:54:58 +0200 Subject: [PATCH 22/32] Reduce coverage build disk usage Compile the throwaway coverage targets without DWARF symbols after two exact-head coverage jobs exhausted runner disk. Keep the test selection and LLVM coverage instrumentation unchanged. --- Makefile | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Makefile b/Makefile index afc340ffc..00060c46b 100644 --- a/Makefile +++ b/Makefile @@ -219,9 +219,12 @@ coverage: ## Generate LCOV coverage over the CI-tested crate subset @# three lint crates whose dylint UI tests are excluded) stay excluded, @# so coverage never attempts a bare `--workspace` build the suite @# cannot support. + @# LLVM coverage maps carry source locations without DWARF debug info. + @# Omit optional symbols from this throwaway target to limit runner disk use. @export PATH="$$PATH:$(TOOL_PATH_SUFFIX)"; command -v cargo-llvm-cov >/dev/null || { echo "Install cargo-llvm-cov (cargo install cargo-llvm-cov)"; exit 1; } @CARGO_LLVM_COV_TARGET_DIR="$(COVERAGE_TARGET_DIR)" \ CARGO_TARGET_DIR="$(COVERAGE_TARGET_DIR)" \ + CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 \ $(MAKE) test TEST_RUNNER="llvm-cov nextest --lcov --output-path $(COVERAGE_OUTPUT)" test-doc: ## Run the workspace doctests, which no other lane executes From 8de12b2f8afda4c47be95de9dbc3040aa9d1e3e6 Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 15 Oct 2026 08:17:49 +0200 Subject: [PATCH 23/32] Document PATH scan helper results Describe the ordered directory scanner and its result type so the developer guide matches the installer implementation. --- docs/developers-guide.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 233139294..e6e4dc84a 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -2503,8 +2503,11 @@ execution either. The `dylint-link` verification in `installer/src/deps/mod.rs` is implemented by six small private helpers: -- `find_binary_on_path(binary_name)` returns the first executable candidate so - the installation check can validate the exact path it found. +- `find_binary_on_path(binary_name)` returns a `PathScanResult` containing the + first executable path and classified failures encountered during the scan. +- `scan_path_directories(directories, binary_name)` scans the supplied ordered + `PathBuf` entries and returns a `PathScanResult` with the first match and + failures from entries checked before that match. - `find_binary_in_directory` checks candidates relative to one PATH entry and returns the first match joined to the original directory. Its inputs make the path, binary name, and failure accumulator explicit: From 97750e5c82a341462381724e5b0c8fc18d28aedb Mon Sep 17 00:00:00 2001 From: leynos Date: Thu, 15 Oct 2026 09:39:52 +0200 Subject: [PATCH 24/32] Bound coverage profiles during nextest Use LLVM online profile merging so separate Nextest processes update one raw profile per instrumented binary instead of retaining one per process. Verify Makefile propagation and document the shared policy. --- Makefile | 5 +++++ docs/developers-guide.md | 4 +++- .../coverage_report_contract_test.py | 19 +++++++++++++++++++ .../test_coverage_target_makefile.py | 18 +++++++++++------- 4 files changed, 38 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 00060c46b..b6903292f 100644 --- a/Makefile +++ b/Makefile @@ -49,6 +49,10 @@ COVERAGE_OUTPUT ?= lcov.info # exact directory. cargo-llvm-cov otherwise passes its target only as a # Nextest argument, which Dylint's nested Cargo build cannot observe. COVERAGE_TARGET_DIR ?= $(CURDIR)/target/llvm-cov-target +# Nextest runs each test case in a separate process. Keep one online-merged +# profile per instrumented binary instead of one raw profile per process, as +# cargo-llvm-cov's default `%p` pattern does. +COVERAGE_PROFILE_FILE_NAME ?= whitaker-%m.profraw RUST_FLAGS ?= -D warnings # The installer MSRV check and the publish check each build in a scratch # tree they discard afterwards. The trees live at fixed paths rather than @@ -224,6 +228,7 @@ coverage: ## Generate LCOV coverage over the CI-tested crate subset @export PATH="$$PATH:$(TOOL_PATH_SUFFIX)"; command -v cargo-llvm-cov >/dev/null || { echo "Install cargo-llvm-cov (cargo install cargo-llvm-cov)"; exit 1; } @CARGO_LLVM_COV_TARGET_DIR="$(COVERAGE_TARGET_DIR)" \ CARGO_TARGET_DIR="$(COVERAGE_TARGET_DIR)" \ + LLVM_PROFILE_FILE_NAME="$(COVERAGE_PROFILE_FILE_NAME)" \ CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 \ $(MAKE) test TEST_RUNNER="llvm-cov nextest --lcov --output-path $(COVERAGE_OUTPUT)" diff --git a/docs/developers-guide.md b/docs/developers-guide.md index e6e4dc84a..31c70bcea 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -308,7 +308,9 @@ are never silently skipped in the pipeline. `make test`, but runs it through `cargo llvm-cov nextest`. The Makefile assigns the absolute `COVERAGE_TARGET_DIR` (by default, `$(CURDIR)/target/llvm-cov-target`) to both `CARGO_LLVM_COV_TARGET_DIR` and -`CARGO_TARGET_DIR`. +`CARGO_TARGET_DIR`. It also sets `LLVM_PROFILE_FILE_NAME` to +`whitaker-%m.profraw`, so LLVM merges each instrumented binary's counters +online instead of retaining a separate raw profile for every Nextest process. The first variable makes `cargo-llvm-cov` instrument its output in that directory. The second is inherited by nested Cargo commands that Dylint UI diff --git a/tests/workflow_contracts/coverage_report_contract_test.py b/tests/workflow_contracts/coverage_report_contract_test.py index 9fff42235..645815d8d 100644 --- a/tests/workflow_contracts/coverage_report_contract_test.py +++ b/tests/workflow_contracts/coverage_report_contract_test.py @@ -136,3 +136,22 @@ def test_only_main_uploads_the_explicit_root_lcov_report( assert inputs.get("format") == "lcov" assert inputs.get("mode") == "upload" assert inputs.get("path") == "lcov.info" + + +def test_coverage_lanes_use_the_shared_online_profile_pool( + workflows: Mapping[str, Mapping[str, Any]], +) -> None: + """Keep both workflows on the Makefile's bounded profile pattern.""" + makefile = (REPOSITORY_ROOT / "Makefile").read_text(encoding="utf-8") + assert "COVERAGE_PROFILE_FILE_NAME ?= whitaker-%m.profraw" in makefile + assert "COVERAGE_PROFILE_FILE_NAME ?= whitaker-%p-%m.profraw" not in makefile + + for workflow_name, job_name in ( + ("ci.yml", "coverage-check"), + ("coverage-main.yml", "coverage-upload"), + ): + steps = _job_steps(workflows[workflow_name], job_name) + generate = steps[_named_step_index(steps, "Generate coverage")] + assert generate.get("run") == "make coverage", ( + f"{workflow_name}:{job_name} must use the shared profile policy" + ) diff --git a/tests/workflows/test_coverage_target_makefile.py b/tests/workflows/test_coverage_target_makefile.py index a973f3915..f62427743 100644 --- a/tests/workflows/test_coverage_target_makefile.py +++ b/tests/workflows/test_coverage_target_makefile.py @@ -16,8 +16,10 @@ def _write_executable(path: Path, body: str) -> None: path.chmod(0o755) -def test_coverage_shares_its_target_with_nested_cargo(tmp_path: Path) -> None: - """Require coverage and nested Cargo to inherit one absolute target.""" +def test_coverage_shares_target_and_profile_policy_with_nested_cargo( + tmp_path: Path, +) -> None: + """Require coverage and nested Cargo to inherit the shared policy.""" workspace = tmp_path / "workspace" bin_directory = tmp_path / "bin" workspace.mkdir() @@ -29,12 +31,14 @@ def test_coverage_shares_its_target_with_nested_cargo(tmp_path: Path) -> None: cargo, 'case "$1" in\n' ' llvm-cov)\n' - ' printf "outer|%s|%s\\n" "$CARGO_LLVM_COV_TARGET_DIR" "$CARGO_TARGET_DIR" ' + ' printf "outer|%s|%s|%s\\n" "$CARGO_LLVM_COV_TARGET_DIR" "$CARGO_TARGET_DIR" ' + '"$LLVM_PROFILE_FILE_NAME" ' '>> "$COVERAGE_ENV_LOG"\n' ' "$0" nested-cargo\n' ' ;;\n' ' nested-cargo)\n' - ' printf "nested|%s|%s\\n" "$CARGO_LLVM_COV_TARGET_DIR" "$CARGO_TARGET_DIR" ' + ' printf "nested|%s|%s|%s\\n" "$CARGO_LLVM_COV_TARGET_DIR" "$CARGO_TARGET_DIR" ' + '"$LLVM_PROFILE_FILE_NAME" ' '>> "$COVERAGE_ENV_LOG"\n' ' ;;\n' 'esac', @@ -64,6 +68,6 @@ def test_coverage_shares_its_target_with_nested_cargo(tmp_path: Path) -> None: assert result.returncode == 0, result.stderr expected_target = workspace / "target" / "llvm-cov-target" assert environment_log.read_text(encoding="utf-8").splitlines() == [ - f"outer|{expected_target}|{expected_target}", - f"nested|{expected_target}|{expected_target}", - ], "coverage and nested Cargo must use the same absolute target directory" + f"outer|{expected_target}|{expected_target}|whitaker-%m.profraw", + f"nested|{expected_target}|{expected_target}|whitaker-%m.profraw", + ], "coverage and nested Cargo must share the target and profile policy" From d4571f247aad8e4959f54cd605e31b6cb44b1de7 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 16 Oct 2026 07:26:47 +0200 Subject: [PATCH 25/32] Harden dependency checks and compile-contract tests Propagate dependency probe errors so failed queries cannot masquerade as missing tools. Cover numeric conversion boundaries and capability-rooted test paths. Give nested Cargo fixtures more time under shared compiler admission, and refresh precise spelling exceptions. --- .config/nextest.toml | 9 +- .github/workflows/ci.yml | 7 +- .github/workflows/coverage-main.yml | 4 +- .../src/driver/diagnostic.rs | 91 +++--- .../bumpy_road_function/src/driver/numeric.rs | 93 +++++- .../src/driver/segment_builder.rs | 82 ++++- .../conditional_max_n_branches/src/driver.rs | 80 +++-- .../src/driver/numeric.rs | 16 +- docs/developers-guide.md | 59 ++-- docs/v0-3-0-migration-guide.md | 20 ++ installer/src/deps/availability_tests.rs | 198 ++++++++++++ installer/src/deps/install.rs | 50 +-- installer/src/deps/install_tests.rs | 10 +- installer/src/deps/mod.rs | 287 +++++++++++++----- installer/src/deps/path_tests.rs | 52 +++- installer/src/deps/tests.rs | 9 +- installer/src/error.rs | 13 + installer/src/install_flow.rs | 29 +- .../src/install_flow/dependency_check.rs | 41 +++ .../install_flow/dependency_check_tests.rs | 44 +++ .../test_utils/dependency_binary_helpers.rs | 27 +- installer/src/tests/mod.rs | 15 +- installer/src/tracing_config.rs | 2 +- installer/tests/tracing_cli.rs | 112 ++++--- .../nextest_profile_test.py | 2 +- .../timeout_ordering_test.py | 12 +- typos.local.toml | 33 +- typos.toml | 29 +- 28 files changed, 1116 insertions(+), 310 deletions(-) create mode 100644 installer/src/deps/availability_tests.rs create mode 100644 installer/src/install_flow/dependency_check.rs create mode 100644 installer/src/install_flow/dependency_check_tests.rs diff --git a/.config/nextest.toml b/.config/nextest.toml index 50b8e73aa..b21252076 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -14,10 +14,11 @@ default-filter = "not (binary(behaviour_toolchain) | binary(behaviour_cli) | kin # cancels the run and discards the log that would have named the test. slow-timeout = { period = "300s", terminate-after = 1, grace-period = "5s" } # The whole run's budget. It sits above the largest per-test allowance below, -# so a test may spend its budget in full. It also leaves fifteen minutes beyond -# the prior whole-run timeout, which stopped a nearly complete suite. +# so a test may spend its budget in full. The previous 75-minute run reached +# 1,840 of 1,842 tests before the nested SARIF API contract was still compiling. +# Ten more minutes give that remaining trybuild work room to finish. # See "Test timeouts: four tiers, outermost last" in the developers' guide. -global-timeout = "60m" +global-timeout = "85m" # CI profile โ€” run the full suite including slow installer tests. [profile.ci] @@ -31,7 +32,7 @@ default-filter = "not kind(example)" # budgets that govern CI belong where a reader of this profile finds # them. slow-timeout = { period = "300s", terminate-after = 1, grace-period = "5s" } -global-timeout = "60m" +global-timeout = "85m" [[profile.ci.overrides]] # The toolchain auto-install behavioural tests may download and install a diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dda46d99a..13ee5788f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,11 +69,11 @@ jobs: coverage-check: if: github.event_name == 'pull_request' runs-on: ubicloud-standard-2-ubuntu-2404 - # Above the 60 m nextest global timeout plus a cold build and the work + # Above the 85 m nextest global timeout plus a cold build and the work # either side of the suite. A ceiling merely above the suite's budget # cancels the job before nextest can report the overrun, and a # cancellation discards the log that would have explained it. - timeout-minutes: 100 + timeout-minutes: 120 defaults: run: shell: bash @@ -505,7 +505,8 @@ jobs: # when the `ci` profile is selected, so the named toolchain and UI # tests were bounded; what nothing bounded was every other test, the # `ci` profile having declared no base `slow-timeout` of its own. - timeout-minutes: 100 + # The 120 m job ceiling includes the 85 m suite budget and cold setup. + timeout-minutes: 120 env: # `windows-latest` is a 4 vCPU GitHub-hosted shape, so it does not share # the Ubicloud constant used by the Linux lanes. diff --git a/.github/workflows/coverage-main.yml b/.github/workflows/coverage-main.yml index 2c1ef5c53..305c2403d 100644 --- a/.github/workflows/coverage-main.yml +++ b/.github/workflows/coverage-main.yml @@ -74,9 +74,9 @@ jobs: # admits `main` alone; the codescene environment contract holds it to # this job. environment: codescene - # Above the 60 m nextest global timeout plus a cold build and the work + # Above the 85 m nextest global timeout plus a cold build and the work # either side of the suite; kept in step with ci.yml's coverage lane. - timeout-minutes: 100 + timeout-minutes: 120 defaults: run: shell: bash diff --git a/crates/bumpy_road_function/src/driver/diagnostic.rs b/crates/bumpy_road_function/src/driver/diagnostic.rs index bd17388f8..b954c5c07 100644 --- a/crates/bumpy_road_function/src/driver/diagnostic.rs +++ b/crates/bumpy_road_function/src/driver/diagnostic.rs @@ -15,7 +15,7 @@ use whitaker_common::{ Arguments, Localizer, MessageResolution, noop_reporter, safe_resolve_message_set, }; -use super::numeric::{saturating_diagnostic_integer, span_byte_offset}; +use super::numeric::{diagnostic_integer_argument, line_range_byte_offsets}; use super::{BUMPY_ROAD_FUNCTION, LINT_NAME, MESSAGE_KEY}; /// Payload describing a lint diagnostic to emit. @@ -37,18 +37,7 @@ pub(super) fn emit_diagnostic( input: DiagnosticInput<'_>, localizer: &Localizer, ) { - let mut args: Arguments<'_> = Arguments::default(); - args.insert(Cow::Borrowed("name"), FluentValue::from(input.name)); - args.insert( - Cow::Borrowed("count"), - FluentValue::from(saturating_diagnostic_integer( - u64::try_from(input.bumps.len()).unwrap_or(u64::MAX), - )), - ); - args.insert( - Cow::Borrowed("threshold"), - FluentValue::from(input.settings.threshold), - ); + let args = diagnostic_arguments(input.name, input.bumps.len(), input.settings.threshold); let resolution = MessageResolution { lint_name: LINT_NAME, @@ -75,12 +64,8 @@ pub(super) fn emit_diagnostic( }; let label = resolve_bump_label( localizer, - saturating_diagnostic_integer( - u64::try_from(ordinal).unwrap_or(u64::MAX).saturating_add(1), - ), - saturating_diagnostic_integer( - u64::try_from(interval.len()).unwrap_or(u64::MAX), - ), + diagnostic_integer_argument(ordinal.saturating_add(1)), + diagnostic_integer_argument(interval.len()), ); lint.span_label(span, label); } @@ -90,6 +75,18 @@ pub(super) fn emit_diagnostic( ); } +/// Build the localized arguments passed by the production diagnostic path. +fn diagnostic_arguments(name: &str, bump_count: usize, threshold: f64) -> Arguments<'_> { + let mut args: Arguments<'_> = Arguments::default(); + args.insert(Cow::Borrowed("name"), FluentValue::from(name)); + args.insert( + Cow::Borrowed("count"), + FluentValue::from(diagnostic_integer_argument(bump_count)), + ); + args.insert(Cow::Borrowed("threshold"), FluentValue::from(threshold)); + args +} + fn build_bump_spans( cx: &LateContext<'_>, body_span: Span, @@ -153,25 +150,17 @@ impl LineSpanMapper { /// Returns `None` when the start line is outside the snippet, the range is /// reversed, or a byte offset cannot fit the compiler's `u32` positions. fn span_for_range(&self, start_line: usize, end_line: usize) -> Option { - if start_line < self.base_line || end_line < start_line { - return None; - } - - let start_index = start_line - self.base_line; - let end_index = end_line - self.base_line; - - let start_offset = *self.line_starts.get(start_index)?; - let end_offset = self - .line_starts - .get(end_index + 1) - .copied() - .unwrap_or(self.snippet_len); - let base = self.base_span.shrink_to_lo(); // `BytePos` is `u32`-backed; offsets beyond 4 GiB cannot be represented // as spans, so such (pathological) snippets yield no span. - let lo = base.lo() + BytePos(span_byte_offset(u64::try_from(start_offset).ok()?)?); - let mut hi = base.lo() + BytePos(span_byte_offset(u64::try_from(end_offset).ok()?)?); + let (start_offset, end_offset) = line_range_byte_offsets( + &self.line_starts, + self.snippet_len, + self.base_line, + start_line..=end_line, + )?; + let lo = base.lo() + BytePos(start_offset); + let mut hi = base.lo() + BytePos(end_offset); if hi <= lo { hi = lo + BytePos(1); } @@ -204,3 +193,35 @@ fn fallback_messages(name: &str, count: usize, threshold: f64) -> DiagnosticMess ), ) } + +#[cfg(test)] +mod tests { + //! Verify diagnostic construction and compiler span bounds. + + use super::{LineSpanMapper, diagnostic_arguments}; + use fluent_templates::fluent_bundle::FluentValue; + use rustc_span::DUMMY_SP; + + /// Verify oversized bump counts saturate in the emitted diagnostic arguments. + #[cfg(target_pointer_width = "64")] + #[test] + fn diagnostic_arguments_saturate_oversized_bump_counts() { + let oversized = usize::try_from(u64::try_from(i64::MAX).expect("i64 max fits u64") + 1) + .expect("oversized bump count fits usize"); + + let args = diagnostic_arguments("example", oversized, 0.5); + + assert_eq!(args.get("count"), Some(&FluentValue::from(i64::MAX))); + } + + /// Verify span construction omits offsets that rustc cannot represent. + #[cfg(target_pointer_width = "64")] + #[test] + fn span_mapping_omits_unrepresentable_offsets() { + let oversized = + usize::try_from(u64::from(u32::MAX) + 1).expect("oversized span offset fits usize"); + let mapper = LineSpanMapper::new(DUMMY_SP, oversized, 1, vec![0, oversized]); + + assert_eq!(mapper.span_for_range(1, 1), None); + } +} diff --git a/crates/bumpy_road_function/src/driver/numeric.rs b/crates/bumpy_road_function/src/driver/numeric.rs index c971c7e8f..37ae8c9e5 100644 --- a/crates/bumpy_road_function/src/driver/numeric.rs +++ b/crates/bumpy_road_function/src/driver/numeric.rs @@ -1,5 +1,7 @@ //! Bounds-checked numeric conversions shared by the bumpy-road diagnostics. +use std::ops::RangeInclusive; + /// Clamp a diagnostic number to the largest value supported by Fluent. pub(super) fn saturating_diagnostic_integer(value: u64) -> i64 { i64::try_from(value).unwrap_or(i64::MAX) @@ -16,11 +18,58 @@ pub(super) fn span_byte_offset(offset: u64) -> Option { u32::try_from(offset).ok() } +/// Convert a production diagnostic argument to Fluent's signed integer range. +pub(super) fn diagnostic_integer_argument(value: usize) -> i64 { + saturating_diagnostic_integer(u64::try_from(value).unwrap_or(u64::MAX)) +} + +/// Compute the production predicate weight with a bounded branch count. +pub(super) fn predicate_segment_weight(branches: usize, weight: f64) -> f64 { + f64::from(saturating_branch_count( + u64::try_from(branches).unwrap_or(u64::MAX), + )) * weight +} + +/// Convert production snippet offsets to compiler byte positions. +pub(super) fn span_byte_offset_range(start: usize, end: usize) -> Option<(u32, u32)> { + let start = u64::try_from(start).ok()?; + let end = u64::try_from(end).ok()?; + Some((span_byte_offset(start)?, span_byte_offset(end)?)) +} + +/// Map an inclusive line range to checked compiler byte offsets. +pub(super) fn line_range_byte_offsets( + line_starts: &[usize], + snippet_len: usize, + base_line: usize, + lines: RangeInclusive, +) -> Option<(u32, u32)> { + let start_line = *lines.start(); + let end_line = *lines.end(); + if start_line < base_line || end_line < start_line { + return None; + } + + let start_index = start_line - base_line; + let end_index = end_line - base_line; + let start_offset = *line_starts.get(start_index)?; + let end_offset = line_starts + .get(end_index + 1) + .copied() + .unwrap_or(snippet_len); + + span_byte_offset_range(start_offset, end_offset) +} + #[cfg(test)] mod tests { //! Verify conversion behaviour at diagnostic and compiler integer limits. - use super::{saturating_branch_count, saturating_diagnostic_integer, span_byte_offset}; + use super::{ + diagnostic_integer_argument, line_range_byte_offsets, predicate_segment_weight, + saturating_branch_count, saturating_diagnostic_integer, span_byte_offset, + span_byte_offset_range, + }; /// Verify values beyond Fluent's signed limit clamp without wrapping. #[test] @@ -48,4 +97,46 @@ mod tests { assert_eq!(span_byte_offset(maximum), Some(u32::MAX)); assert_eq!(span_byte_offset(maximum + 1), None); } + + /// Verify the diagnostic call-site conversion clamps an oversized count. + #[cfg(target_pointer_width = "64")] + #[test] + fn production_diagnostic_arguments_saturate_at_the_signed_boundary() { + let oversized = usize::try_from(u64::try_from(i64::MAX).expect("i64 max fits u64") + 1) + .expect("oversized diagnostic count fits usize"); + assert_eq!(diagnostic_integer_argument(oversized), i64::MAX); + } + + /// Verify the production segment-weight conversion clamps excess branches. + #[cfg(target_pointer_width = "64")] + #[test] + fn production_predicate_weight_saturates_excess_branches() { + let oversized = + usize::try_from(u64::from(u32::MAX) + 1).expect("oversized branch count fits usize"); + assert_eq!( + predicate_segment_weight(oversized, 0.5), + f64::from(u32::MAX) * 0.5, + ); + } + + /// Verify the production span-offset conversion rejects an unrepresentable end. + #[cfg(target_pointer_width = "64")] + #[test] + fn production_span_offset_ranges_reject_an_unrepresentable_end() { + let oversized = + usize::try_from(u64::from(u32::MAX) + 1).expect("oversized span offset fits usize"); + assert_eq!(span_byte_offset_range(0, oversized), None); + } + + /// Verify the production line-to-span mapping rejects unrepresentable ranges. + #[cfg(target_pointer_width = "64")] + #[test] + fn production_line_range_mapping_rejects_an_unrepresentable_offset() { + let oversized = + usize::try_from(u64::from(u32::MAX) + 1).expect("oversized span offset fits usize"); + assert_eq!( + line_range_byte_offsets(&[0, oversized], oversized, 1, 1..=1), + None, + ); + } } diff --git a/crates/bumpy_road_function/src/driver/segment_builder.rs b/crates/bumpy_road_function/src/driver/segment_builder.rs index 68ab9eb9d..b021719d4 100644 --- a/crates/bumpy_road_function/src/driver/segment_builder.rs +++ b/crates/bumpy_road_function/src/driver/segment_builder.rs @@ -13,7 +13,41 @@ use rustc_span::source_map::SourceMap; use rustc_span::{DesugaringKind, Span}; use whitaker_common::complexity_signal::LineSegment; -use super::numeric::saturating_branch_count; +use super::numeric::predicate_segment_weight; + +/// Describe a fixed or branch-scaled contribution to a line segment. +#[derive(Clone, Copy)] +enum SegmentWeight { + /// Use the supplied value unchanged. + Fixed(f64), + /// Scale by a branch count after clamping it to the supported range. + Predicate { branches: usize, per_branch: f64 }, +} + +impl SegmentWeight { + /// Convert the selected production weight to the value stored in a segment. + fn value(self) -> f64 { + match self { + Self::Fixed(value) => value, + Self::Predicate { + branches, + per_branch, + } => predicate_segment_weight(branches, per_branch), + } + } +} + +/// Construct the line segment after applying the production weight policy. +/// +/// # Errors +/// +/// Returns an error when the inclusive line range is not one-based and ordered. +fn line_segment( + lines: RangeInclusive, + weight: SegmentWeight, +) -> Result { + LineSegment::new(*lines.start(), *lines.end(), weight.value()) +} pub(super) struct SegmentBuilder<'a, 'tcx> { cx: &'a LateContext<'tcx>, @@ -129,11 +163,11 @@ impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { } fn push_depth_segment(&mut self, span: Span) { - self.push_segment(span, self.settings.weights.depth); + self.push_segment(span, SegmentWeight::Fixed(self.settings.weights.depth)); } fn push_flow_segment(&mut self, span: Span) { - self.push_segment(span, self.settings.weights.flow); + self.push_segment(span, SegmentWeight::Fixed(self.settings.weights.flow)); } /// Add a predicate-weighted segment unless the condition is a `let` test. @@ -145,14 +179,16 @@ impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { return; } - let branches = f64::from(saturating_branch_count( - u64::try_from(count_branches(expr)).unwrap_or(u64::MAX), - )); - let value = branches * self.settings.weights.predicate; - self.push_segment(expr.span, value); + self.push_segment( + expr.span, + SegmentWeight::Predicate { + branches: count_branches(expr), + per_branch: self.settings.weights.predicate, + }, + ); } - fn push_segment(&mut self, span: Span, value: f64) { + fn push_segment(&mut self, span: Span, weight: SegmentWeight) { // Skip spans from macro expansions; their line numbers can point to // locations outside the function (e.g., the macro definition site). if span.from_expansion() { @@ -178,7 +214,7 @@ impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { return; } - let segment = match LineSegment::new(*lines.start(), *lines.end(), value) { + let segment = match line_segment(lines, weight) { Ok(segment) => segment, Err(error) => { self.cx @@ -250,3 +286,29 @@ pub(super) fn span_line_range(source_map: &SourceMap, span: Span) -> Option = MessageKey::new(LINT_NAME); @@ -225,33 +225,7 @@ fn emit_diagnostic( limit: usize, localizer: &Localizer, ) { - let mut args: Arguments<'static> = Arguments::default(); - args.insert( - Cow::Borrowed("name"), - FluentValue::from(metadata.kind.display_name()), - ); - args.insert( - Cow::Borrowed("branches"), - FluentValue::from(saturating_diagnostic_integer( - u64::try_from(metadata.branches).unwrap_or(u64::MAX), - )), - ); - args.insert( - Cow::Borrowed("limit"), - FluentValue::from(saturating_diagnostic_integer( - u64::try_from(limit).unwrap_or(u64::MAX), - )), - ); - let branch_phrase_text = branch_phrase(localizer.locale(), metadata.branches); - args.insert( - Cow::Borrowed("branch_phrase"), - FluentValue::String(Cow::Owned(branch_phrase_text)), - ); - let limit_phrase_text = branch_phrase(localizer.locale(), limit); - args.insert( - Cow::Borrowed("limit_phrase"), - FluentValue::String(Cow::Owned(limit_phrase_text)), - ); + let args = diagnostic_arguments(metadata.kind, metadata.branches, limit, localizer); let resolution = MessageResolution { lint_name: LINT_NAME, @@ -277,6 +251,39 @@ fn emit_diagnostic( ); } +/// Build the production branch-count and limit arguments for a diagnostic. +fn diagnostic_arguments( + kind: ConditionKind, + branches: usize, + limit: usize, + localizer: &Localizer, +) -> Arguments<'static> { + let mut args: Arguments<'static> = Arguments::default(); + args.insert( + Cow::Borrowed("name"), + FluentValue::from(kind.display_name()), + ); + args.insert( + Cow::Borrowed("branches"), + FluentValue::from(diagnostic_integer_argument(branches)), + ); + args.insert( + Cow::Borrowed("limit"), + FluentValue::from(diagnostic_integer_argument(limit)), + ); + let branch_phrase_text = branch_phrase(localizer.locale(), branches); + args.insert( + Cow::Borrowed("branch_phrase"), + FluentValue::String(Cow::Owned(branch_phrase_text)), + ); + let limit_phrase_text = branch_phrase(localizer.locale(), limit); + args.insert( + Cow::Borrowed("limit_phrase"), + FluentValue::String(Cow::Owned(limit_phrase_text)), + ); + args +} + /// Replace injected bidi isolates and replacement characters in diagnostics. /// /// Each such character becomes a double quote; text without these characters @@ -323,9 +330,12 @@ fn fallback_messages(kind: ConditionKind, branches: usize, limit: usize) -> Diag #[cfg(test)] mod tests { + //! Verify branch thresholds and the production diagnostic argument path. + use super::*; use rstest::rstest; + /// Verify branch counts below, at, and above the configured limit. #[rstest] #[case(1, 2, ConditionDisposition::WithinLimit)] #[case(2, 2, ConditionDisposition::WithinLimit)] @@ -337,6 +347,20 @@ mod tests { ) { assert_eq!(evaluate_condition(branches, limit), expected); } + + /// Verify oversized branch counts saturate before Fluent argument creation. + #[cfg(target_pointer_width = "64")] + #[test] + fn diagnostic_arguments_saturate_oversized_branch_counts() { + let oversized = usize::try_from(u64::try_from(i64::MAX).expect("i64 max fits u64") + 1) + .expect("oversized branch count fits usize"); + let localizer = Localizer::new(None); + + let args = diagnostic_arguments(ConditionKind::If, oversized, oversized, &localizer); + + assert_eq!(args.get("branches"), Some(&FluentValue::from(i64::MAX))); + assert_eq!(args.get("limit"), Some(&FluentValue::from(i64::MAX))); + } } #[cfg(test)] diff --git a/crates/conditional_max_n_branches/src/driver/numeric.rs b/crates/conditional_max_n_branches/src/driver/numeric.rs index 3f7d380dc..321bd96d9 100644 --- a/crates/conditional_max_n_branches/src/driver/numeric.rs +++ b/crates/conditional_max_n_branches/src/driver/numeric.rs @@ -5,11 +5,16 @@ pub(super) fn saturating_diagnostic_integer(value: u64) -> i64 { i64::try_from(value).unwrap_or(i64::MAX) } +/// Convert a production diagnostic argument to Fluent's signed integer range. +pub(super) fn diagnostic_integer_argument(value: usize) -> i64 { + saturating_diagnostic_integer(u64::try_from(value).unwrap_or(u64::MAX)) +} + #[cfg(test)] mod tests { //! Verify diagnostic integer conversion at Fluent's signed limit. - use super::saturating_diagnostic_integer; + use super::{diagnostic_integer_argument, saturating_diagnostic_integer}; /// Verify values beyond Fluent's signed limit clamp without wrapping. #[test] @@ -19,4 +24,13 @@ mod tests { assert_eq!(saturating_diagnostic_integer(maximum), i64::MAX); assert_eq!(saturating_diagnostic_integer(maximum + 1), i64::MAX); } + + /// Verify the production argument conversion clamps an oversized limit. + #[cfg(target_pointer_width = "64")] + #[test] + fn production_diagnostic_arguments_saturate_at_the_signed_boundary() { + let oversized = usize::try_from(u64::try_from(i64::MAX).expect("i64 max fits u64") + 1) + .expect("oversized diagnostic limit fits usize"); + assert_eq!(diagnostic_integer_argument(oversized), i64::MAX); + } } diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 31c70bcea..d6d16afcb 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1179,7 +1179,7 @@ worker count rather than `-n auto`. #### Coverage lane time limits -`coverage-check` and `coverage-upload` run under the 100-minute ceiling that +`coverage-check` and `coverage-upload` run under the 120-minute ceiling that "Test timeouts: four tiers, outermost last" derives from the suite's budgets, and that section's contract pins it. The ceiling must also hold a cold compiler cache, not only a warm one. A cold cache is not rare: every change to the @@ -1201,8 +1201,8 @@ CodeScene upload and three cache saves), could not finish cold at all. A cancelled writer keeps the compiler-cache entries it stored while it compiled, which is why the next trunk run was warm, but it loses its doctests, its CodeScene upload and its archive saves. The slowest cold job measured, 38m45s, -sits well inside the 100-minute ceiling, which leaves `coverage-upload` about -an hour after a cold `Generate coverage` for its doctests, upload and saves. +sits well inside the 120-minute ceiling, which leaves `coverage-upload` about +81 minutes after a cold `Generate coverage` for its doctests, upload and saves. Re-measure the cold figure after a change that makes the suite materially larger; if it approaches the ceiling, the tier derivation is where to raise it, not a per-lane value. @@ -2515,10 +2515,10 @@ six small private helpers: path, binary name, and failure accumulator explicit: ```rust - fn find_binary_in_directory<'binary>( + fn find_binary_in_directory( directory: &Path, - binary_name: &'binary str, - failures: &mut Vec>, + binary_name: &str, + failures: &mut Vec, ) -> Option ``` @@ -2542,10 +2542,9 @@ six small private helpers: ``` It obtains metadata with `Dir::metadata`, requires a regular file and, on - Unix, at least one executable permission bit. Metadata errors always return - `false`; ordinary `NotFound` misses add no failure category, while other - metadata errors add the bounded `metadata` category to the caller-provided - list. + Unix, at least one executable permission bit. Ordinary `NotFound` misses add + no failure category, while other metadata errors add the bounded `metadata` + category to the caller-provided list. - `windows_path_extensions()` normalizes `PATHEXT` on Windows so `binary_candidates()` can expand extensionless names the same way the shell does. @@ -2557,11 +2556,25 @@ these classifications without logging. At verbose level, the installer CLI emits structured debug events with `binary_name` and `failure_category`; it does not record PATH values or candidate paths. +`check_dylint_tools()` returns `Result`. A successful status +with a `false` field means the query established that a tool is absent or does +not match the pinned version. Command-spawn and Cargo-registry errors propagate +as errors; if PATH scanning finds no binary after a non-missing lookup failure, +it returns `InstallerError::DependencyCheck`. The install flow only starts +installation after a successful query reports a missing tool. The query uses the +`CommandExecutor` and `BinaryLocator` interfaces, and returns PATH diagnostics +without emitting tracing events. The CLI logs those diagnostics at the command +boundary. Cargo's explicit `no such command: dylint` response is treated as an +absent tool; other nonzero command results and successful version probes that +report no semantic version are query errors. + These key helpers are covered by direct unit tests in `installer/src/deps/path_tests.rs` for missing PATH values, empty PATH values, multiple PATH directories, non-executable Unix files, executable Unix files, broken PATH shims, and Windows `PATHEXT` resolution via both direct helper -tests and `check_dylint_tools()`. +tests and `check_dylint_tools()`. Availability tests also verify that query +failures remain errors, do not emit tracing events, and stop before the install +callback. Installer PATH-fixture helpers now live in `installer/src/test_utils/dependency_binary_helpers.rs` instead of being @@ -3961,9 +3974,9 @@ tier went. | Tier | What it bounds | Where it is set | Current value | | ------------------------ | ---------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | Per-test `slow-timeout` | one test | `.config/nextest.toml`, both profiles | 300 s default; 20 m for serialized Dylint/trybuild UI harnesses; 30 m for serialized nested-Cargo fixtures; 30 m for toolchain-installing behaviour tests | -| nextest `global-timeout` | the whole test run | `.config/nextest.toml`, both profiles | 60 m | +| nextest `global-timeout` | the whole test run | `.config/nextest.toml`, both profiles | 85 m | | Cargo watchdog | one `cargo` invocation, wall clock | not used here, see below | absent | -| Job `timeout-minutes` | the whole job | `ci.yml` and `coverage-main.yml`, job level | 100 m for every lane that runs the suite | +| Job `timeout-minutes` | the whole job | `ci.yml` and `coverage-main.yml`, job level | 120 m for every lane that runs the suite | *Table: the timers that can end a run, innermost first.* @@ -4054,10 +4067,10 @@ for the build and the steps either side of the suite, plus a fifteen-minute margin above that sum. The margin is there because a ceiling equal to the sum it contains cancels the job at the moment the innermost timer would have reported the overrun, and the report is the only thing that makes an overrun -actionable. That is 60 m + 65 s + 15 m + 15 m, or 91 minutes 5 seconds, which +actionable. That is 85 m + 65 s + 15 m + 15 m, or 116 minutes 5 seconds, which `required_ceiling` computes for both profiles. -The ceiling is not that requirement. It is pinned separately at 100 minutes, a +The ceiling is not that requirement. It is pinned separately at 120 minutes, a policy value above it: the derivation accepts any ceiling at or above the requirement, so the value is pinned as well, to stop a lane drifting to a number nobody chose. @@ -4068,11 +4081,11 @@ written down, so it has been corrected rather than kept as a special case. ### What the values are sized against -The 60 m whole-run budget is a bound with headroom for the complete workspace -suite. A recent full run reached the previous 45 m limit with 1,759 of 1,762 -tests completed while the final nested trybuild compile was still progressing. -The additional 15 m leaves time for that compile and normal variation without -weakening the 30 m per-test ceiling. +The 85 m whole-run budget is sized against the latest uncached full run. It +reached 1,840 of 1,842 tests before the global timeout stopped the SARIF API +contract's nested trybuild compilation while Cargo waited on shared locks and +compiler admission. The additional 10 m gives that contract and the remaining +test room to finish without weakening the 30 m per-test ceiling. The 15 m allowance for work outside nextest's window is measured, from the worst of several runs rather than one: @@ -4101,7 +4114,7 @@ and this suite cannot run a bare `--workspace` build. Coverage runs through That absence is asserted rather than assumed. A lane that adopted the action without setting `RUN_RUST_CARGO_WAIT_TIMEOUT` would inherit the 1,800 s default -underneath a 60 m nextest budget, which is exactly the inversion the canonical +underneath an 85 m nextest budget, which is exactly the inversion the canonical section exists to prevent, and it would do so silently. The contract therefore fails if either the action appears or the variable is set, so adopting it needs this section updated in the same change. @@ -4272,9 +4285,9 @@ fails `ci`'s ordering too. Whether a profile terminates a test no override matches is still read from its own table alone. It pins the values the tables above state as well as ordering them: the base -`slow-timeout` on both profiles compared as a whole table, the 60 m whole-run +`slow-timeout` on both profiles compared as a whole table, the 85 m whole-run budget, both overrides' whole `slow-timeout` including the grace period, and -the 100-minute ceiling on every suite lane. The ordering assertions hold for a +the 120-minute ceiling on every suite lane. The ordering assertions hold for a range of values, so on their own they would let any of these drift to a number nobody chose while still passing. The grace period is pinned for the same reason it is read: the watchdog and the ceiling above it are sized to cover diff --git a/docs/v0-3-0-migration-guide.md b/docs/v0-3-0-migration-guide.md index 49b440976..b946ad59e 100644 --- a/docs/v0-3-0-migration-guide.md +++ b/docs/v0-3-0-migration-guide.md @@ -133,3 +133,23 @@ The Rust names change while the SARIF wire names remain fixed. The `#[serde(rename = "artifactLocation")]`. Consequently, serialized JSON still uses the SARIF 2.1.0 keys `artifacts` and `artifactLocation`; no update to stored SARIF data or downstream SARIF readers is required. + +## Installer dependency query + +`whitaker_installer::deps::check_dylint_tools` now returns +`Result` so callers can distinguish a missing or unpinned +tool from a failed availability query. Propagate the result before deciding +whether to install: + +```rust +let status = check_dylint_tools(&executor)?; +if !status.all_installed() { + install_dylint_tools(&executor, &status)?; +} +``` + +The error enum also adds `InstallerError::DependencyCheck`. Update exhaustive +matches on `InstallerError` to handle this query failure. A missing tool or a +version mismatch remains a successful query with a `false` status field. +Unexpected command failures or version output without a semantic version now +return an error instead of being treated as permission to install. diff --git a/installer/src/deps/availability_tests.rs b/installer/src/deps/availability_tests.rs new file mode 100644 index 000000000..6c012e3f9 --- /dev/null +++ b/installer/src/deps/availability_tests.rs @@ -0,0 +1,198 @@ +//! Verify dependency queries preserve failure state without emitting events. + +use super::{ + BinaryLocator, PathScanFailure, PathScanFailureCategory, PathScanResult, + check_dylint_tools_with_locator, +}; +use crate::test_utils::{ + ExpectedCall, StubExecutor, + dependency_binary_helpers::{cargo_dylint_check, dylint_link_install_list_check}, + failure_output, +}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tracing::Subscriber; +use tracing_subscriber::{Layer, layer::Context, prelude::*}; + +struct FixedBinaryLocator { + binary: Option, + failure: Option, +} + +impl BinaryLocator for FixedBinaryLocator { + /// Return the configured fixture result for the requested executable. + fn find_binary(&self, binary_name: &str) -> PathScanResult { + let failures = self + .failure + .map(|category| { + vec![PathScanFailure { + binary_name: binary_name.to_owned(), + category, + }] + }) + .unwrap_or_default(); + PathScanResult::new(self.binary.clone(), failures) + } +} + +struct EventCounter(Arc); + +impl Layer for EventCounter +where + S: Subscriber, +{ + /// Count events emitted while a dependency query is in scope. + fn on_event(&self, _event: &tracing::Event<'_>, _context: Context<'_, S>) { + self.0.fetch_add(1, Ordering::Relaxed); + } +} + +/// Verify successful dependency lookup returns diagnostics without tracing them. +#[test] +fn dependency_query_returns_path_diagnostics_without_emitting_events() { + let event_count = Arc::new(AtomicUsize::new(0)); + let subscriber = tracing_subscriber::registry().with(EventCounter(Arc::clone(&event_count))); + let locator = FixedBinaryLocator { + binary: Some(PathBuf::from("/fixture/dylint-link")), + failure: Some(PathScanFailureCategory::DirectoryOpen), + }; + let executor = StubExecutor::new(vec![ + cargo_dylint_check().expect("cargo-dylint version should resolve"), + dylint_link_install_list_check().expect("dylint-link version should resolve"), + ]); + + let check = tracing::subscriber::with_default(subscriber, || { + check_dylint_tools_with_locator(&executor, &locator) + .expect("a later PATH match should establish availability") + }); + + assert_eq!(event_count.load(Ordering::Relaxed), 0); + assert_eq!(check.path_scan_failures.len(), 1); + assert_eq!( + check.path_scan_failures[0].category, + PathScanFailureCategory::DirectoryOpen + ); + assert!(check.status.all_installed()); + executor.assert_finished(); +} + +/// Verify unresolved PATH failures make availability queries fail. +#[rstest::rstest] +#[case::path(PathScanFailureCategory::DirectoryOpen, "directory_open")] +#[case::metadata(PathScanFailureCategory::Metadata, "metadata")] +fn unresolved_path_failures_are_errors( + #[case] category: PathScanFailureCategory, + #[case] expected_category: &str, +) { + let locator = FixedBinaryLocator { + binary: None, + failure: Some(category), + }; + let executor = StubExecutor::new(vec![ + cargo_dylint_check().expect("cargo-dylint version should resolve"), + ]); + + let error = check_dylint_tools_with_locator(&executor, &locator) + .expect_err("a failed PATH query must not report a missing tool"); + + assert!(error.to_string().contains(expected_category)); + executor.assert_finished(); +} + +/// Verify command spawn errors remain visible to the dependency caller. +#[test] +fn command_spawn_errors_are_propagated() { + let locator = FixedBinaryLocator { + binary: None, + failure: None, + }; + let executor = StubExecutor::new(vec![ExpectedCall { + cmd: "cargo", + args: vec!["dylint", "--version"], + result: Err(crate::error::InstallerError::Io(std::io::Error::other( + "cargo could not be started", + ))), + }]); + + let error = check_dylint_tools_with_locator(&executor, &locator) + .expect_err("a command spawn error must not look like a missing tool"); + + assert!(error.to_string().contains("cargo could not be started")); + executor.assert_finished(); +} + +/// Verify operational command failures are not treated as missing tools. +#[test] +fn command_failures_are_propagated() { + let locator = FixedBinaryLocator { + binary: None, + failure: None, + }; + let executor = StubExecutor::new(vec![ExpectedCall { + cmd: "cargo", + args: vec!["dylint", "--version"], + result: Ok(failure_output( + "cargo-dylint failed to read its configuration", + )), + }]); + + let error = check_dylint_tools_with_locator(&executor, &locator) + .expect_err("an operational command failure must not look like a missing tool"); + + assert!( + error + .to_string() + .contains("failed to read its configuration") + ); + executor.assert_finished(); +} + +/// Verify Cargo's explicit missing-subcommand response establishes absence. +#[test] +fn missing_cargo_subcommand_is_reported_as_absent() { + let locator = FixedBinaryLocator { + binary: None, + failure: None, + }; + let executor = StubExecutor::new(vec![ExpectedCall { + cmd: "cargo", + args: vec!["dylint", "--version"], + result: Ok(failure_output("error: no such command: `dylint`")), + }]); + + let check = check_dylint_tools_with_locator(&executor, &locator) + .expect("Cargo's explicit missing-subcommand result establishes absence"); + + assert_eq!( + check.status, + crate::deps::DylintToolStatus { + cargo_dylint: false, + dylint_link: false, + } + ); + executor.assert_finished(); +} + +/// Verify failed registry reads remain visible to the dependency caller. +#[test] +fn failed_registry_command_is_propagated() { + let locator = FixedBinaryLocator { + binary: Some(PathBuf::from("/fixture/dylint-link")), + failure: None, + }; + let executor = StubExecutor::new(vec![ + cargo_dylint_check().expect("cargo-dylint version should resolve"), + ExpectedCall { + cmd: "cargo", + args: vec!["install", "--list"], + result: Ok(failure_output("registry failure")), + }, + ]); + + let error = check_dylint_tools_with_locator(&executor, &locator) + .expect_err("a failed registry command must not look like an absent package"); + + assert!(error.to_string().contains("registry failure")); + executor.assert_finished(); +} diff --git a/installer/src/deps/install.rs b/installer/src/deps/install.rs index e0ec81606..438f3b3a1 100644 --- a/installer/src/deps/install.rs +++ b/installer/src/deps/install.rs @@ -43,7 +43,7 @@ pub(super) fn install_missing_tools( } let outcome = install_tool(executor, tool, stderr, context)?; - update_status_after_install(&mut remaining_status, executor, tool, outcome); + update_status_after_install(&mut remaining_status, executor, tool, outcome)?; } Ok(()) @@ -64,12 +64,12 @@ pub(super) fn repository_install_context<'a>( } } -pub(super) fn cargo_fallback_mode(executor: &dyn CommandExecutor) -> InstallMode { - if is_binstall_available(executor) { +pub(super) fn cargo_fallback_mode(executor: &dyn CommandExecutor) -> Result { + Ok(if is_binstall_available(executor)? { InstallMode::Binstall } else { InstallMode::CargoInstall - } + }) } pub(super) fn should_install_tool(status: &DylintToolStatus, tool: &DependencyTool) -> bool { @@ -109,15 +109,15 @@ pub(super) fn install_tool( cargo_install_plan = cargo_install_plan.with_version(dependency.version()); match repo.installer.install(dependency, repo.target, repo.dirs) { - Ok(_) if repository_install_verified(executor, tool) => { - write_message( - stderr, - context.quiet, - format!("Installed {} from repository release.", tool.package), - ); - return Ok(InstallOutcome::RepositoryRelease); - } Ok(_) => { + if repository_install_verified(executor, tool)? { + write_message( + stderr, + context.quiet, + format!("Installed {} from repository release.", tool.package), + ); + return Ok(InstallOutcome::RepositoryRelease); + } refuse_or_announce_fallback(tool, "failed verification", stderr, context)?; } Err(error) if error.is_not_found() => { @@ -270,7 +270,7 @@ fn try_binstall( return Ok(false); } - if !is_tool_installed(executor, cargo_install_plan.tool) { + if !is_tool_installed(executor, cargo_install_plan.tool)? { return Ok(false); } @@ -315,7 +315,7 @@ fn run_cargo_install( }); } - if !is_tool_installed(executor, cargo_install_plan.tool) { + if !is_tool_installed(executor, cargo_install_plan.tool)? { return Err(InstallerError::DependencyInstall { tool: cargo_install_plan.tool.package, message: format!( @@ -348,9 +348,12 @@ fn run_cargo_install( /// /// `cargo-dylint` keeps the generic check because it reports its own version /// and must additionally be discoverable by Cargo as a subcommand. -fn repository_install_verified(executor: &dyn CommandExecutor, tool: &DependencyTool) -> bool { +fn repository_install_verified( + executor: &dyn CommandExecutor, + tool: &DependencyTool, +) -> Result { if tool == &DYLINT_LINK_TOOL { - return true; + return Ok(true); } is_tool_installed(executor, tool) } @@ -372,10 +375,12 @@ pub(super) fn write_message(stderr: &mut dyn Write, quiet: bool, message: String let _ = writeln!(stderr, "{message}"); } -pub(super) fn command_succeeds(executor: &dyn CommandExecutor, cmd: &str, args: &[&str]) -> bool { - executor - .run(cmd, args) - .is_ok_and(|output| output.status.success()) +pub(super) fn command_succeeds( + executor: &dyn CommandExecutor, + cmd: &str, + args: &[&str], +) -> Result { + Ok(executor.run(cmd, args)?.status.success()) } fn should_refresh_companions(outcome: InstallOutcome, status: &DylintToolStatus) -> bool { @@ -387,17 +392,18 @@ fn update_status_after_install( executor: &dyn CommandExecutor, tool: &DependencyTool, outcome: InstallOutcome, -) { +) -> Result<()> { if tool == &CARGO_DYLINT_TOOL { status.cargo_dylint = true; if should_refresh_companions(outcome, status) { // Installing cargo-dylint locally can also provide dylint-link. - status.dylint_link = is_tool_installed(executor, &DYLINT_LINK_TOOL); + status.dylint_link = is_tool_installed(executor, &DYLINT_LINK_TOOL)?; } } else if tool == &DYLINT_LINK_TOOL { status.dylint_link = true; } + Ok(()) } #[cfg(test)] diff --git a/installer/src/deps/install_tests.rs b/installer/src/deps/install_tests.rs index 5b352bb65..59784eba0 100644 --- a/installer/src/deps/install_tests.rs +++ b/installer/src/deps/install_tests.rs @@ -7,6 +7,7 @@ use crate::test_utils::dependency_binary_helpers::{ dylint_link_install_list_check, with_fake_binary_on_path, }; +/// Verify local Cargo installs refresh companion link status. #[rstest] #[case(InstallOutcome::CargoBinstall)] #[case(InstallOutcome::CargoInstall)] @@ -25,7 +26,8 @@ fn update_status_after_install_refreshes_link_for_local_cargo_dylint_installs( dylint_link: false, }; - update_status_after_install(&mut status, &executor, &CARGO_DYLINT_TOOL, outcome); + update_status_after_install(&mut status, &executor, &CARGO_DYLINT_TOOL, outcome) + .expect("local Cargo install status should refresh"); assert!(status.cargo_dylint); assert!(status.dylint_link); @@ -34,6 +36,7 @@ fn update_status_after_install_refreshes_link_for_local_cargo_dylint_installs( Ok(()) } +/// Verify repository releases skip the companion link query. #[test] fn update_status_after_install_skips_link_probe_for_repository_release() { let executor = crate::test_utils::StubExecutor::new(vec![]); @@ -47,13 +50,15 @@ fn update_status_after_install_skips_link_probe_for_repository_release() { &executor, &CARGO_DYLINT_TOOL, InstallOutcome::RepositoryRelease, - ); + ) + .expect("repository release status should refresh"); assert!(status.cargo_dylint); assert!(!status.dylint_link); executor.assert_finished(); } +/// Verify installation decisions for both dependency tool statuses. #[rstest] #[case(false, false, &CARGO_DYLINT_TOOL, true)] #[case(true, false, &CARGO_DYLINT_TOOL, false)] @@ -73,6 +78,7 @@ fn should_install_tool_returns_expected( assert_eq!(should_install_tool(&status, tool), expected); } +/// Verify companion refresh decisions for install outcomes and current status. #[rstest] #[case::non_repo_release_and_link_missing( InstallOutcome::CargoInstall, diff --git a/installer/src/deps/mod.rs b/installer/src/deps/mod.rs index ce7235fc9..60eced4d0 100644 --- a/installer/src/deps/mod.rs +++ b/installer/src/deps/mod.rs @@ -16,7 +16,6 @@ use std::io; use std::io::Write; use std::path::Path; use std::process::{Command, Output}; -use tracing::debug; mod install; use install::*; @@ -67,15 +66,19 @@ const DEPENDENCY_TOOLS: [DependencyTool; 2] = [CARGO_DYLINT_TOOL, DYLINT_LINK_TO /// Stable, bounded failure labels returned while checking a PATH entry. #[derive(Clone, Copy, Debug, Eq, PartialEq)] -enum PathScanFailureCategory { +#[doc(hidden)] +pub enum PathScanFailureCategory { + /// A PATH entry cannot be represented as UTF-8 for capability-based access. NonUtf8Path, + /// A PATH entry cannot be opened as a directory capability. DirectoryOpen, + /// Candidate metadata cannot be read through the directory capability. Metadata, } impl PathScanFailureCategory { /// Return the low-cardinality field value used by trace events. - const fn as_str(self) -> &'static str { + pub const fn as_str(self) -> &'static str { match self { Self::NonUtf8Path => "non_utf8_path", Self::DirectoryOpen => "directory_open", @@ -84,17 +87,54 @@ impl PathScanFailureCategory { } } -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -struct PathScanFailure<'binary> { - binary_name: &'binary str, - category: PathScanFailureCategory, +#[derive(Clone, Debug, Eq, PartialEq)] +#[doc(hidden)] +pub struct PathScanFailure { + /// Executable being searched for; no filesystem path is retained. + pub binary_name: String, + /// Bounded reason a PATH entry was skipped. + pub category: PathScanFailureCategory, } /// Collect the first match and classified failures for one PATH scan. #[derive(Debug, Default)] -struct PathScanResult<'binary> { +pub(crate) struct PathScanResult { binary: Option, - failures: Vec>, + failures: Vec, +} + +impl PathScanResult { + /// Build a result for a found binary and any skipped-entry failures. + #[cfg(test)] + fn new(binary: Option, failures: Vec) -> Self { + Self { binary, failures } + } +} + +/// Query PATH and its filesystem entries for one executable. +pub(crate) trait BinaryLocator { + /// Return the first matching executable and bounded skipped-entry details. + fn find_binary(&self, binary_name: &str) -> PathScanResult; +} + +/// Use the process PATH and host filesystem to locate dependency binaries. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct SystemBinaryLocator; + +impl BinaryLocator for SystemBinaryLocator { + fn find_binary(&self, binary_name: &str) -> PathScanResult { + find_binary_on_path(binary_name) + } +} + +/// Status and diagnostics from a successful availability query. +#[derive(Debug)] +#[doc(hidden)] +pub struct DylintToolCheck { + /// Availability for each required tool. + pub status: DylintToolStatus, + /// PATH entries skipped before a match was found. + pub path_scan_failures: Vec, } /// Classify metadata errors, omitting the ordinary miss for an absent candidate. @@ -102,15 +142,6 @@ fn metadata_failure_category(kind: io::ErrorKind) -> Option { pub policy: SourcePolicy, } -/// Checks whether the Dylint tools are installed. -#[must_use] -pub fn check_dylint_tools(executor: &dyn CommandExecutor) -> DylintToolStatus { - DylintToolStatus { - cargo_dylint: is_tool_installed(executor, &CARGO_DYLINT_TOOL), - dylint_link: is_tool_installed(executor, &DYLINT_LINK_TOOL), - } +/// Check whether the Dylint tools are installed. +/// +/// A failed command or PATH query is returned as an error so an uncertain +/// result cannot be mistaken for permission to install a missing tool. +/// +/// # Errors +/// +/// Returns an error when a command, registry, or PATH query cannot establish +/// the availability of a dependency. +pub fn check_dylint_tools(executor: &dyn CommandExecutor) -> Result { + check_dylint_tools_with_locator(executor, &SystemBinaryLocator).map(|check| check.status) +} + +/// Check tool availability and return bounded PATH diagnostics for the caller. +/// +/// The command boundary owns any tracing of returned diagnostics. The query +/// itself has no logging side effects. +/// +/// # Errors +/// +/// Returns an error when a command, registry, or PATH query cannot establish +/// the availability of a dependency. +#[doc(hidden)] +pub fn check_dylint_tools_with_diagnostics( + executor: &dyn CommandExecutor, +) -> Result { + check_dylint_tools_with_locator(executor, &SystemBinaryLocator) +} + +/// Check tool availability while retaining PATH diagnostics for the caller. +pub(crate) fn check_dylint_tools_with_locator( + executor: &dyn CommandExecutor, + locator: &dyn BinaryLocator, +) -> Result { + let mut path_scan_failures = Vec::new(); + let cargo_dylint = is_tool_installed_with_locator( + executor, + &CARGO_DYLINT_TOOL, + locator, + &mut path_scan_failures, + )?; + let dylint_link = is_tool_installed_with_locator( + executor, + &DYLINT_LINK_TOOL, + locator, + &mut path_scan_failures, + )?; + + Ok(DylintToolCheck { + status: DylintToolStatus { + cargo_dylint, + dylint_link, + }, + path_scan_failures, + }) } /// Install missing tools without emitting progress output. @@ -192,7 +271,7 @@ pub fn install_dylint_tools_with_output( let system_dirs = SystemBaseDirs::new(); let target = host_target(); let dirs = system_dirs.as_ref().map(|dirs| dirs as &dyn BaseDirs); - let cargo_fallback_mode = cargo_fallback_mode(executor); + let cargo_fallback_mode = cargo_fallback_mode(executor)?; install_missing_tools( executor, status, @@ -219,7 +298,7 @@ pub fn install_dylint_tools_with_options( stderr: &mut dyn Write, options: DependencyInstallOptions<'_>, ) -> Result<()> { - let cargo_fallback_mode = cargo_fallback_mode(executor); + let cargo_fallback_mode = cargo_fallback_mode(executor)?; install_missing_tools( executor, status, @@ -243,19 +322,32 @@ const CARGO_INSTALL_LIST_ARGS: [&str; 2] = ["install", "--list"]; /// Check whether a dependency tool is installed at its manifest version. /// /// Dylint-link is checked through PATH and Cargo's install list because the -/// wrapper cannot reliably report its own version. If embedded version lookup -/// fails, other tools fall back to a success-only command probe. -fn is_tool_installed(executor: &dyn CommandExecutor, tool: &DependencyTool) -> bool { - // The manifest is embedded in the binary, so a lookup failure is - // effectively unreachable. Should it ever occur, degrade to the previous - // success-only probes rather than reporting every tool as missing. - let expected_version = find_dependency_binary(tool.package) - .ok() - .flatten() - .map(|dependency| dependency.version()); +/// wrapper cannot reliably report its own version. The embedded dependency +/// manifest must provide the expected version before availability is decided. +fn is_tool_installed(executor: &dyn CommandExecutor, tool: &DependencyTool) -> Result { + is_tool_installed_with_locator(executor, tool, &SystemBinaryLocator, &mut Vec::new()) +} + +/// Check one tool while returning any PATH failures to the application boundary. +fn is_tool_installed_with_locator( + executor: &dyn CommandExecutor, + tool: &DependencyTool, + locator: &dyn BinaryLocator, + path_scan_failures: &mut Vec, +) -> Result { + let dependency = find_dependency_binary(tool.package) + .map_err(|error| InstallerError::DependencyCheck { + tool: tool.package, + reason: format!("dependency manifest could not be read: {error}"), + })? + .ok_or_else(|| InstallerError::DependencyCheck { + tool: tool.package, + reason: String::from("dependency manifest has no pinned version"), + })?; + let expected_version = dependency.version(); if tool == &DYLINT_LINK_TOOL { - return is_dylint_link_installed(executor, expected_version); + return is_dylint_link_installed(executor, expected_version, locator, path_scan_failures); } is_versioned_tool_installed(executor, tool, expected_version) } @@ -267,48 +359,74 @@ fn is_tool_installed(executor: &dyn CommandExecutor, tool: &DependencyTool) -> b /// list; skipped-entry classifications are traced here, without logging paths. fn is_dylint_link_installed( executor: &dyn CommandExecutor, - expected_version: Option<&str>, -) -> bool { + expected_version: &str, + locator: &dyn BinaryLocator, + path_scan_failures: &mut Vec, +) -> Result { // Presence is established by resolving an executable file on PATH. // `dylint-link` is a linker wrapper with no reliable self-reporting // subcommand, so it is never executed to prove it works. - let path_scan = find_binary_on_path(DYLINT_LINK_TOOL.command); - for failure in &path_scan.failures { - debug_path_scan_failure(failure.binary_name, failure.category); - } + let path_scan = locator.find_binary(DYLINT_LINK_TOOL.command); + path_scan_failures.extend(path_scan.failures.iter().cloned()); if path_scan.binary.is_none() { - return false; + if path_scan.failures.is_empty() { + return Ok(false); + } + let categories = path_scan + .failures + .iter() + .map(|failure| failure.category.as_str()) + .collect::>() + .join(", "); + return Err(InstallerError::DependencyCheck { + tool: DYLINT_LINK_TOOL.package, + reason: format!( + "PATH scanning encountered {categories} failures and found no executable" + ), + }); } - let Some(expected_version) = expected_version else { - return true; - }; // `dylint-link` is a pure linker wrapper: it forwards its entire argument // list to the underlying linker and can never report its own version, so // `dylint-link --version` is not a usable version source. Query Cargo's // registry of installed binaries instead, which records the version each // binary was installed at. - cargo_installed_version(executor, DYLINT_LINK_TOOL.package) - .is_some_and(|version| version == expected_version) + Ok(cargo_installed_version(executor, DYLINT_LINK_TOOL.package)? + .is_some_and(|version| version == expected_version)) } /// Probe a tool and, when available, compare its reported version. /// -/// Without an expected version, command success alone means installed. With -/// one, the command must succeed and stdout's first `MAJOR.MINOR.PATCH`-shaped -/// token must exactly match it; command errors or mismatches return `false`. +/// A valid version mismatch means the pinned tool is absent. Unexpected command +/// failures and successful output without a version are query errors. fn is_versioned_tool_installed( executor: &dyn CommandExecutor, tool: &DependencyTool, - expected_version: Option<&str>, -) -> bool { - let Some(expected_version) = expected_version else { - return command_succeeds(executor, tool.command, tool.args); + expected_version: &str, +) -> Result { + let output = executor.run(tool.command, tool.args)?; + if !output.status.success() { + if tool == &CARGO_DYLINT_TOOL && cargo_reports_missing_subcommand(&output) { + return Ok(false); + } + return Err(InstallerError::DependencyCheck { + tool: tool.package, + reason: command_error_message(&output), + }); + } + let stdout = String::from_utf8_lossy(&output.stdout); + let Some(actual_version) = first_semver_token(&stdout) else { + return Err(InstallerError::DependencyCheck { + tool: tool.package, + reason: String::from("version command succeeded without reporting a semantic version"), + }); }; - executor.run(tool.command, tool.args).is_ok_and(|output| { - output.status.success() - && first_semver_token(&String::from_utf8_lossy(&output.stdout)) - .is_some_and(|version| version == expected_version) - }) + Ok(actual_version == expected_version) +} + +/// Recognize Cargo's explicit diagnostic for an absent `cargo-dylint` command. +fn cargo_reports_missing_subcommand(output: &Output) -> bool { + let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase(); + stderr.contains("no such command") && stderr.contains("dylint") } /// Extracts the installed version of `package` from `cargo install --list`. @@ -316,13 +434,19 @@ fn is_versioned_tool_installed( /// Matches lines of the form `dylint-link v6.0.1:` (or /// `dylint-link v6.0.1 (/path):` for path installs), returning the version /// with the leading `v` and trailing `:` stripped. -fn cargo_installed_version(executor: &dyn CommandExecutor, package: &str) -> Option { - let output = executor.run("cargo", &CARGO_INSTALL_LIST_ARGS).ok()?; +fn cargo_installed_version( + executor: &dyn CommandExecutor, + package: &'static str, +) -> Result> { + let output = executor.run("cargo", &CARGO_INSTALL_LIST_ARGS)?; if !output.status.success() { - return None; + return Err(InstallerError::DependencyCheck { + tool: package, + reason: command_error_message(&output), + }); } let stdout = String::from_utf8_lossy(&output.stdout); - stdout.lines().find_map(|line| { + Ok(stdout.lines().find_map(|line| { let mut tokens = line.split_whitespace(); if tokens.next() != Some(package) { return None; @@ -331,7 +455,7 @@ fn cargo_installed_version(executor: &dyn CommandExecutor, package: &str) -> Opt .next() .and_then(|token| token.strip_prefix('v')) .map(|version| version.trim_end_matches(':').to_owned()) - }) + })) } /// Returns the first whitespace-separated token shaped like `MAJOR.MINOR.PATCH`. @@ -351,7 +475,7 @@ fn first_semver_token(text: &str) -> Option<&str> { /// Return whether `cargo binstall --version` exits successfully. /// /// This checks command availability only; it does not compare a version. -fn is_binstall_available(executor: &dyn CommandExecutor) -> bool { +fn is_binstall_available(executor: &dyn CommandExecutor) -> Result { command_succeeds(executor, "cargo", &["binstall", "--version"]) } @@ -361,14 +485,17 @@ fn is_binstall_available(executor: &dyn CommandExecutor) -> bool { /// package version. #[cfg(test)] fn is_binary_on_path(binary_name: &str) -> bool { - find_binary_on_path(binary_name).binary.is_some() + SystemBinaryLocator + .find_binary(binary_name) + .binary + .is_some() } /// Find the first executable match while retaining skipped-entry classifications. /// /// Return an empty report when PATH is unset. Otherwise, scan entries in their /// configured order and return the classifications without emitting logs. -fn find_binary_on_path<'binary>(binary_name: &'binary str) -> PathScanResult<'binary> { +fn find_binary_on_path(binary_name: &str) -> PathScanResult { let Some(path_var) = std::env::var_os("PATH") else { return PathScanResult::default(); }; @@ -381,10 +508,10 @@ fn find_binary_on_path<'binary>(binary_name: &'binary str) -> PathScanResult<'bi /// The report preserves every skipped-entry category before the first match so /// the installer boundary can emit bounded diagnostics without coupling the /// filesystem query helpers to tracing. -fn scan_path_directories<'binary>( +fn scan_path_directories( directories: impl IntoIterator, - binary_name: &'binary str, -) -> PathScanResult<'binary> { + binary_name: &str, +) -> PathScanResult { let mut result = PathScanResult::default(); for directory in directories { result.binary = find_binary_in_directory(&directory, binary_name, &mut result.failures); @@ -402,14 +529,14 @@ fn scan_path_directories<'binary>( /// inspected through the opened directory capability rather than ambient /// filesystem access. Failures are returned as categories without retaining /// the directory path. -fn find_binary_in_directory<'binary>( +fn find_binary_in_directory( directory: &Path, - binary_name: &'binary str, - failures: &mut Vec>, + binary_name: &str, + failures: &mut Vec, ) -> Option { let Some(utf8_directory) = Utf8Path::from_path(directory) else { failures.push(PathScanFailure { - binary_name, + binary_name: binary_name.to_owned(), category: PathScanFailureCategory::NonUtf8Path, }); return None; @@ -418,7 +545,7 @@ fn find_binary_in_directory<'binary>( Ok(directory_capability) => directory_capability, Err(_) => { failures.push(PathScanFailure { - binary_name, + binary_name: binary_name.to_owned(), category: PathScanFailureCategory::DirectoryOpen, }); return None; @@ -439,7 +566,7 @@ fn find_binary_in_directory<'binary>( failure_categories .into_iter() .map(|category| PathScanFailure { - binary_name, + binary_name: binary_name.to_owned(), category, }), ); @@ -543,6 +670,8 @@ fn is_executable_file( } } +#[cfg(test)] +mod availability_tests; #[cfg(test)] mod path_tests; #[cfg(test)] diff --git a/installer/src/deps/path_tests.rs b/installer/src/deps/path_tests.rs index e99d5d213..a797f9f99 100644 --- a/installer/src/deps/path_tests.rs +++ b/installer/src/deps/path_tests.rs @@ -31,7 +31,8 @@ fn check_dylint_tools_reports_installed_tools() -> std::io::Result<()> { .expect("dylint-link dependency version should resolve"), ]); - let status = check_dylint_tools(&executor); + let status = + check_dylint_tools(&executor).expect("available dependency checks should complete"); assert_eq!( status, @@ -47,7 +48,6 @@ fn check_dylint_tools_reports_installed_tools() -> std::io::Result<()> { #[rstest::rstest] #[case::stale_version("cargo-dylint 5.0.0\n")] -#[case::unparsable_output("not a version\n")] fn check_dylint_tools_rejects_unusable_cargo_dylint_output( #[case] version_stdout: &str, ) -> std::io::Result<()> { @@ -59,7 +59,8 @@ fn check_dylint_tools_rejects_unusable_cargo_dylint_output( stdout_output(version_stdout), ))]); - let status = check_dylint_tools(&executor); + let status = + check_dylint_tools(&executor).expect("available dependency checks should complete"); assert_eq!( status, @@ -74,6 +75,29 @@ fn check_dylint_tools_rejects_unusable_cargo_dylint_output( Ok(()) } +#[test] +fn check_dylint_tools_rejects_unparsable_cargo_dylint_output() -> std::io::Result<()> { + with_fake_path( + |_| Ok(()), + || { + let executor = StubExecutor::new(vec![cargo_dylint_check_with_result(Ok( + stdout_output("not a version\n"), + ))]); + + let error = check_dylint_tools(&executor) + .expect_err("unparsable version output must not authorize installation"); + + assert!( + error + .to_string() + .contains("without reporting a semantic version") + ); + executor.assert_finished(); + }, + )?; + Ok(()) +} + #[rstest::rstest] #[case::stale_version(dylint_link_install_list_check_with_version("5.0.0"))] #[case::missing_from_list(ExpectedCall { @@ -90,7 +114,8 @@ fn check_dylint_tools_rejects_unpinned_dylint_link( install_list_check, ]); - let status = check_dylint_tools(&executor); + let status = + check_dylint_tools(&executor).expect("available dependency checks should complete"); assert_eq!( status, @@ -104,8 +129,9 @@ fn check_dylint_tools_rejects_unpinned_dylint_link( Ok(()) } +/// Verify PATH detection never executes the discovered dylint-link wrapper. #[test] -fn check_dylint_tools_rejects_non_invocable_dylint_link_on_path() -> std::io::Result<()> { +fn check_dylint_tools_does_not_invoke_dylint_link_on_path() -> std::io::Result<()> { with_fake_path( |directories| { let first_dir = &directories[0]; @@ -119,15 +145,18 @@ fn check_dylint_tools_rejects_non_invocable_dylint_link_on_path() -> std::io::Re || { let executor = StubExecutor::new(vec![ cargo_dylint_check().expect("cargo-dylint dependency version should resolve"), + dylint_link_install_list_check() + .expect("dylint-link dependency version should resolve"), ]); - let status = check_dylint_tools(&executor); + let status = + check_dylint_tools(&executor).expect("available dependency checks should complete"); assert_eq!( status, DylintToolStatus { cargo_dylint: true, - dylint_link: false, + dylint_link: true, } ); executor.assert_finished(); @@ -249,11 +278,11 @@ fn path_scan_classifies_invalid_entries_and_continues_to_a_later_binary() -> std scan.failures, vec![ PathScanFailure { - binary_name: "dylint-link", + binary_name: "dylint-link".to_owned(), category: PathScanFailureCategory::NonUtf8Path, }, PathScanFailure { - binary_name: "dylint-link", + binary_name: "dylint-link".to_owned(), category: PathScanFailureCategory::DirectoryOpen, }, ] @@ -335,7 +364,7 @@ fn path_scan_ordering_holds_for_bounded_match_patterns() { .take_while(|entry_kind| **entry_kind != 2) .filter(|entry_kind| **entry_kind == 1) .map(|_| PathScanFailure { - binary_name: "probe.exe", + binary_name: "probe.exe".to_owned(), category: PathScanFailureCategory::DirectoryOpen, }) .collect::>(); @@ -383,7 +412,8 @@ fn check_dylint_tools_detects_dylint_link_via_pathext_suffix() -> std::io::Resul .expect("dylint-link dependency version should resolve"), ]); - let status = check_dylint_tools(&executor); + let status = check_dylint_tools(&executor) + .expect("available dependency checks should complete"); assert_eq!( status, diff --git a/installer/src/deps/tests.rs b/installer/src/deps/tests.rs index 5b568c5e6..0f2b1d7ca 100644 --- a/installer/src/deps/tests.rs +++ b/installer/src/deps/tests.rs @@ -7,7 +7,9 @@ use crate::test_utils::dependency_binary_helpers::{ binstall_install, binstall_version_check_with_result, cargo_dylint_check, cargo_dylint_check_with_result, dylint_link_install_list_check, with_fake_binary_on_path, }; -use crate::test_utils::{ExpectedCall, StubDirs, StubExecutor, failure_output, success_output}; +use crate::test_utils::{ + ExpectedCall, StubDirs, StubExecutor, failure_output, stdout_output, success_output, +}; use std::path::PathBuf; fn install_options<'a>( @@ -136,15 +138,16 @@ fn install_dylint_tools_falls_back_to_cargo_install_when_binstall_missing() { executor.assert_finished(); } +/// Exercise source fallback when repository verification reports a stale version. #[test] -fn install_dylint_tools_falls_back_when_repository_verification_fails() { +fn install_dylint_tools_falls_back_when_repository_verification_reports_a_stale_version() { let mut repository_installer = MockDependencyBinaryInstaller::new(); repository_installer .expect_install() .returning(|_, _, _| Ok(PathBuf::from("/tmp/bin/cargo-dylint"))); let executor = StubExecutor::new(vec![ binstall_version_check_with_result(Ok(success_output())), - cargo_dylint_check_with_result(Ok(failure_output("still missing"))), + cargo_dylint_check_with_result(Ok(stdout_output("cargo-dylint 5.0.0\n"))), binstall_install("cargo-dylint", Ok(success_output())) .expect("cargo-dylint dependency version should resolve"), cargo_dylint_check().expect("cargo-dylint dependency version should resolve"), diff --git a/installer/src/error.rs b/installer/src/error.rs index 41bb4ece8..6c2749634 100644 --- a/installer/src/error.rs +++ b/installer/src/error.rs @@ -173,6 +173,15 @@ pub enum InstallerError { message: String, }, + /// A dependency availability query could not establish whether a tool is installed. + #[error("failed to check whether {tool} is installed: {reason}")] + DependencyCheck { + /// Name of the tool whose availability could not be established. + tool: &'static str, + /// Description of the command or filesystem query failure. + reason: String, + }, + /// Wrapper script generation failed. #[error("wrapper script generation failed: {0}")] WrapperGeneration(String), @@ -285,6 +294,10 @@ impl Clone for InstallerError { tool, message: message.clone(), }, + Self::DependencyCheck { tool, reason } => Self::DependencyCheck { + tool, + reason: reason.clone(), + }, Self::ConflictingSourceOptions { option } => Self::ConflictingSourceOptions { option: option.clone(), }, diff --git a/installer/src/install_flow.rs b/installer/src/install_flow.rs index 5b873f455..0ec234f18 100644 --- a/installer/src/install_flow.rs +++ b/installer/src/install_flow.rs @@ -13,9 +13,7 @@ use std::time::Duration; use whitaker_installer::builder::{library_extension, library_prefix}; use whitaker_installer::cli::InstallArgs; use whitaker_installer::crate_name::CrateName; -use whitaker_installer::deps::{ - CommandExecutor, SourcePolicy, check_dylint_tools, install_dylint_tools_with_output, -}; +use whitaker_installer::deps::{CommandExecutor, SourcePolicy, install_dylint_tools_with_output}; #[cfg(test)] use whitaker_installer::deps::{DependencyInstallOptions, install_dylint_tools_with_options}; use whitaker_installer::dirs::BaseDirs; @@ -26,6 +24,10 @@ use whitaker_installer::prebuilt::{PrebuiltConfig, PrebuiltResult, attempt_prebu use whitaker_installer::prebuilt_path::prebuilt_library_dir; use whitaker_installer::resolution::{EXPERIMENTAL_LINT_CRATES, LINT_CRATES, SUITE_CRATE}; +mod dependency_check; +#[cfg(test)] +pub(crate) use dependency_check::check_dylint_tools_for_command; + pub(crate) fn ensure_dylint_tools_core( quiet: bool, stderr: &mut dyn Write, @@ -55,10 +57,12 @@ pub(crate) fn ensure_dylint_tools_with_executor( policy: SourcePolicy, stderr: &mut dyn Write, ) -> Result<()> { - let status = check_dylint_tools(executor); - ensure_dylint_tools_core(policy.quiet, stderr, status.all_installed(), |stderr| { - install_dylint_tools_with_output(executor, &status, policy, stderr) - }) + dependency_check::ensure_dylint_tools_with_query( + policy.quiet, + stderr, + || dependency_check::check_dylint_tools_for_command(executor), + |status, stderr| install_dylint_tools_with_output(executor, status, policy, stderr), + ) } #[cfg(test)] @@ -67,12 +71,11 @@ pub(crate) fn ensure_dylint_tools_with_options( stderr: &mut dyn Write, options: DependencyInstallOptions<'_>, ) -> Result<()> { - let status = check_dylint_tools(executor); - ensure_dylint_tools_core( + dependency_check::ensure_dylint_tools_with_query( options.policy.quiet, stderr, - status.all_installed(), - |stderr| install_dylint_tools_with_options(executor, &status, stderr, options), + || dependency_check::check_dylint_tools_for_command(executor), + |status, stderr| install_dylint_tools_with_options(executor, status, stderr, options), ) } @@ -371,3 +374,7 @@ fn write_metrics_summary(quiet: bool, record_outcome: &RecordOutcome, stderr: &m #[cfg(test)] mod tests; + +#[cfg(test)] +#[path = "install_flow/dependency_check_tests.rs"] +mod dependency_check_tests; diff --git a/installer/src/install_flow/dependency_check.rs b/installer/src/install_flow/dependency_check.rs new file mode 100644 index 000000000..44c4d569d --- /dev/null +++ b/installer/src/install_flow/dependency_check.rs @@ -0,0 +1,41 @@ +//! Query Dylint dependency availability before the install-flow decision. + +use std::io::Write; +use tracing::debug; +use whitaker_installer::deps::{ + CommandExecutor, DylintToolStatus, PathScanFailure, check_dylint_tools_with_diagnostics, +}; +use whitaker_installer::error::Result; + +/// Query dependency availability and emit bounded PATH diagnostics. +pub(crate) fn check_dylint_tools_for_command( + executor: &dyn CommandExecutor, +) -> Result { + let check = check_dylint_tools_with_diagnostics(executor)?; + report_path_scan_failures(&check.path_scan_failures); + Ok(check.status) +} + +/// Report skipped PATH entries without exposing their paths. +fn report_path_scan_failures(failures: &[PathScanFailure]) { + for failure in failures { + debug!( + binary_name = ?failure.binary_name, + failure_category = failure.category.as_str(), + "skipping PATH scan candidate after lookup failure" + ); + } +} + +/// Install only after a successful query establishes which tools are missing. +pub(super) fn ensure_dylint_tools_with_query( + quiet: bool, + stderr: &mut dyn Write, + query: impl FnOnce() -> Result, + install: impl FnOnce(&DylintToolStatus, &mut dyn Write) -> Result<()>, +) -> Result<()> { + let status = query()?; + super::ensure_dylint_tools_core(quiet, stderr, status.all_installed(), |stderr| { + install(&status, stderr) + }) +} diff --git a/installer/src/install_flow/dependency_check_tests.rs b/installer/src/install_flow/dependency_check_tests.rs new file mode 100644 index 000000000..4817a17ab --- /dev/null +++ b/installer/src/install_flow/dependency_check_tests.rs @@ -0,0 +1,44 @@ +//! Verify failed dependency queries stop before the installation decision. + +use super::dependency_check::ensure_dylint_tools_with_query; +use rstest::rstest; +use std::cell::Cell; +use whitaker_installer::error::InstallerError; + +/// Verify failed availability queries stop before tool installation begins. +#[rstest] +#[case::path_lookup(InstallerError::DependencyCheck { + tool: "dylint-link", + reason: "PATH directory could not be opened".to_owned(), +})] +#[case::metadata_lookup(InstallerError::DependencyCheck { + tool: "dylint-link", + reason: "PATH candidate metadata could not be read".to_owned(), +})] +#[case::command_query(InstallerError::Io(std::io::Error::other( + "dependency command could not be started", +)))] +fn query_failures_do_not_start_tool_installation(#[case] query_error: InstallerError) { + let install_started = Cell::new(false); + let mut stderr = Vec::new(); + let expected_message = query_error.to_string(); + + let result = ensure_dylint_tools_with_query( + false, + &mut stderr, + || Err(query_error), + |_, _| { + install_started.set(true); + Ok(()) + }, + ); + + assert_eq!( + result + .expect_err("a failed availability query must stop the flow") + .to_string(), + expected_message, + ); + assert!(!install_started.get()); + assert!(stderr.is_empty()); +} diff --git a/installer/src/test_utils/dependency_binary_helpers.rs b/installer/src/test_utils/dependency_binary_helpers.rs index 854a1cb22..dc16aab45 100644 --- a/installer/src/test_utils/dependency_binary_helpers.rs +++ b/installer/src/test_utils/dependency_binary_helpers.rs @@ -126,6 +126,20 @@ pub fn cargo_dylint_version_output() -> HelperResult { ))) } +/// Creates an expected Cargo probe reporting a stale `cargo-dylint` version. +pub fn cargo_dylint_stale_version_check() -> HelperResult { + let pinned_version = dependency_version("cargo-dylint")?; + let stale_version = if pinned_version.starts_with("0.") { + "1.0.0" + } else { + "0.0.0" + }; + + Ok(cargo_dylint_check_with_result(Ok(stdout_output(format!( + "cargo-dylint {stale_version}\n" + ))))) +} + /// Expected `cargo install --list` call reporting the manifest-pinned /// `dylint-link` version. pub fn dylint_link_install_list_check() -> HelperResult { @@ -153,14 +167,11 @@ pub fn repository_verification_call( verification_fails: bool, ) -> HelperResult> { Ok(match tool { - "cargo-dylint" => Some(ExpectedCall { - cmd: "cargo", - args: vec!["dylint", "--version"], - result: if verification_fails { - Ok(failure_output("still missing")) - } else { - Ok(cargo_dylint_version_output()?) - }, + "cargo-dylint" => Some(if verification_fails { + // A stale version is a verification miss; command failures are query errors. + cargo_dylint_stale_version_check()? + } else { + cargo_dylint_check()? }), // Repository installs of dylint-link are verified by probing the // extracted binary directly, so neither outcome consults the diff --git a/installer/src/tests/mod.rs b/installer/src/tests/mod.rs index 138fdcb78..ad090b630 100644 --- a/installer/src/tests/mod.rs +++ b/installer/src/tests/mod.rs @@ -13,7 +13,8 @@ use whitaker_installer::{ installer_packaging::TargetTriple, test_utils::{ dependency_binary_helpers::{ - AlwaysNotFoundRepositoryInstaller, cargo_dylint_check, dylint_link_install_list_check, + AlwaysNotFoundRepositoryInstaller, cargo_dylint_check, + cargo_dylint_stale_version_check, dylint_link_install_list_check, with_fake_binary_on_path, }, *, @@ -162,11 +163,7 @@ fn ensure_dylint_tools_installs_missing_tools( ) -> std::io::Result<()> { with_fake_binary_on_path("dylint-link", || { let executor = StubExecutor::new(vec![ - ExpectedCall { - cmd: "cargo", - args: vec!["dylint", "--version"], - result: Ok(failure_output("missing cargo-dylint")), - }, + cargo_dylint_stale_version_check().expect("stale cargo-dylint version should resolve"), dylint_link_install_list_check() .expect("dylint-link dependency version should resolve"), ExpectedCall { @@ -216,11 +213,7 @@ fn ensure_dylint_tools_propagates_install_failures( ) -> std::io::Result<()> { with_fake_binary_on_path("dylint-link", || { let executor = StubExecutor::new(vec![ - ExpectedCall { - cmd: "cargo", - args: vec!["dylint", "--version"], - result: Ok(failure_output("missing cargo-dylint")), - }, + cargo_dylint_stale_version_check().expect("stale cargo-dylint version should resolve"), dylint_link_install_list_check() .expect("dylint-link dependency version should resolve"), ExpectedCall { diff --git a/installer/src/tracing_config.rs b/installer/src/tracing_config.rs index 911795d64..5ac5a6e09 100644 --- a/installer/src/tracing_config.rs +++ b/installer/src/tracing_config.rs @@ -139,7 +139,7 @@ mod tests { let arguments = std::iter::once("whitaker-installer").chain(flags.iter().copied()); let cli = Cli::try_parse_from(arguments).expect("probe arguments must match the CLI"); configure_tracing(cli.install_args()); - let _ = whitaker_installer::deps::check_dylint_tools( + let _ = crate::install_flow::check_dylint_tools_for_command( &whitaker_installer::deps::SystemCommandExecutor, ); } diff --git a/installer/tests/tracing_cli.rs b/installer/tests/tracing_cli.rs index 952c853e2..be504c949 100644 --- a/installer/tests/tracing_cli.rs +++ b/installer/tests/tracing_cli.rs @@ -2,19 +2,23 @@ #![cfg(unix)] +use camino::Utf8PathBuf; +use cap_std::{ + ambient_authority, + fs_utf8::{Dir, Permissions, PermissionsExt}, +}; use std::{ error::Error, ffi::OsString, - fs, io, - os::unix::fs::PermissionsExt, - path::{Path, PathBuf}, + io, process::{Command, Output}, }; /// Keep an isolated workspace, PATH, and home directories alive for CLI runs. struct TracingFixture { - root: tempfile::TempDir, - workspace: PathBuf, + _root: tempfile::TempDir, + root_path: Utf8PathBuf, + workspace: Utf8PathBuf, environment: Vec<(OsString, OsString)>, } @@ -27,65 +31,86 @@ impl TracingFixture { /// versions cannot be read from the embedded manifest. fn new() -> Result> { let root = tempfile::tempdir()?; - let workspace = root.path().join("workspace"); - let binary_directory = root.path().join("bin"); - let invalid_path_entry = root.path().join("not-a-directory"); - fs::create_dir(&workspace)?; - fs::create_dir(&binary_directory)?; - fs::write(&invalid_path_entry, b"PATH entry is a file")?; - fs::write( - workspace.join("Cargo.toml"), + let root_path = Utf8PathBuf::from_path_buf(root.path().to_path_buf()).map_err(|path| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("temporary fixture path is not UTF-8: {}", path.display()), + ) + })?; + let root_dir = Dir::open_ambient_dir(&root_path, ambient_authority())?; + let workspace = root_path.join("workspace"); + let binary_directory = root_path.join("bin"); + root_dir.create_dir("workspace")?; + root_dir.create_dir("bin")?; + root_dir.write("not-a-directory", b"PATH entry is a file")?; + let workspace_dir = root_dir.open_dir("workspace")?; + let binary_dir = root_dir.open_dir("bin")?; + workspace_dir.write( + "Cargo.toml", "[package]\nname = \"whitaker\"\nversion = \"0.0.0\"\nedition = \"2024\"\n", )?; let cargo_version = dependency_version("cargo-dylint")?; let link_version = dependency_version("dylint-link")?; write_executable( - &binary_directory.join("cargo"), + &binary_dir, + "cargo", &fake_cargo_script(cargo_version, link_version), )?; - write_executable(&binary_directory.join("dylint-link"), "#!/bin/sh\nexit 0\n")?; + write_executable(&binary_dir, "dylint-link", "#!/bin/sh\nexit 0\n")?; write_executable( - &binary_directory.join("rustup"), + &binary_dir, + "rustup", "#!/bin/sh\necho 'controlled rustup failure' >&2\nexit 1\n", )?; - let data_home = root.path().join("data"); - let config_home = root.path().join("config"); - let cache_home = root.path().join("cache"); - let rustup_home = root.path().join("rustup"); - let cargo_home = root.path().join("cargo-home"); - for directory in [ - &data_home, - &config_home, - &cache_home, - &rustup_home, - &cargo_home, - ] { - fs::create_dir(directory)?; + for directory in ["data", "config", "cache", "rustup", "cargo-home"] { + root_dir.create_dir(directory)?; } - let path = std::env::join_paths([&invalid_path_entry, &binary_directory])?; + let data_home = root_path.join("data"); + let config_home = root_path.join("config"); + let cache_home = root_path.join("cache"); + let rustup_home = root_path.join("rustup"); + let cargo_home = root_path.join("cargo-home"); + let invalid_path_entry = root_path.join("not-a-directory"); + let path = std::env::join_paths([ + invalid_path_entry.as_std_path().as_os_str(), + binary_directory.as_std_path().as_os_str(), + ])?; let mut environment = vec![ (OsString::from("PATH"), path), - (OsString::from("HOME"), root.path().as_os_str().to_owned()), - (OsString::from("XDG_DATA_HOME"), data_home.into_os_string()), + ( + OsString::from("HOME"), + root_path.as_std_path().as_os_str().to_owned(), + ), + ( + OsString::from("XDG_DATA_HOME"), + data_home.as_std_path().as_os_str().to_owned(), + ), ( OsString::from("XDG_CONFIG_HOME"), - config_home.into_os_string(), + config_home.as_std_path().as_os_str().to_owned(), ), ( OsString::from("XDG_CACHE_HOME"), - cache_home.into_os_string(), + cache_home.as_std_path().as_os_str().to_owned(), + ), + ( + OsString::from("RUSTUP_HOME"), + rustup_home.as_std_path().as_os_str().to_owned(), + ), + ( + OsString::from("CARGO_HOME"), + cargo_home.as_std_path().as_os_str().to_owned(), ), - (OsString::from("RUSTUP_HOME"), rustup_home.into_os_string()), - (OsString::from("CARGO_HOME"), cargo_home.into_os_string()), ]; if let Some(ld_library_path) = std::env::var_os("LD_LIBRARY_PATH") { environment.push((OsString::from("LD_LIBRARY_PATH"), ld_library_path)); } Ok(Self { - root, + _root: root, + root_path, workspace, environment, }) @@ -120,7 +145,7 @@ fn run_cli_mode(fixture: &TracingFixture, arguments: &[&str]) -> io::Result Result<&'static str, Box> { /// /// Returns a filesystem error if the script or its executable permissions /// cannot be written. -fn write_executable(path: &Path, contents: &str) -> io::Result<()> { - fs::write(path, contents)?; - fs::set_permissions(path, fs::Permissions::from_mode(0o755)) +fn write_executable(directory: &Dir, path: &str, contents: &str) -> io::Result<()> { + directory.write(path, contents)?; + directory.set_permissions(path, Permissions::from_mode(0o755)) } diff --git a/tests/workflow_contracts/nextest_profile_test.py b/tests/workflow_contracts/nextest_profile_test.py index 72523238e..92edc02fe 100644 --- a/tests/workflow_contracts/nextest_profile_test.py +++ b/tests/workflow_contracts/nextest_profile_test.py @@ -41,7 +41,7 @@ } #: The whole-run budget both profiles must declare. -REQUIRED_GLOBAL_TIMEOUT: typ.Final[str] = "60m" +REQUIRED_GLOBAL_TIMEOUT: typ.Final[str] = "85m" # Parser-pin fixtures share one integration binary, target directory and Cargo # package cache, so the allowance must cover that whole binary and serialize it. diff --git a/tests/workflow_contracts/timeout_ordering_test.py b/tests/workflow_contracts/timeout_ordering_test.py index af471e044..142e5bdb0 100644 --- a/tests/workflow_contracts/timeout_ordering_test.py +++ b/tests/workflow_contracts/timeout_ordering_test.py @@ -17,8 +17,8 @@ The third tier of the canonical four, the shared coverage action's cargo watchdog, does not exist here and its absence is asserted rather than assumed: `coverage-main.yml` says why the action is not used, and a lane -that adopted it would inherit an undocumented 1,800 s default beneath a -60 m nextest budget. See "Test timeouts: four tiers, outermost last" in +that adopted it would inherit an undocumented 1,800 s default beneath an +85 m nextest budget. See "Test timeouts: four tiers, outermost last" in ``docs/developers-guide.md``, and the canonical wording in `leynos/shared-actions`' `generate-coverage` README. @@ -277,7 +277,7 @@ def test_the_cargo_watchdog_tier_is_absent_rather_than_defaulted() -> None: `cargo llvm-cov` itself and does not use that action, so the tier is absent by choice. A lane that adopted the action without setting `RUN_RUST_CARGO_WAIT_TIMEOUT` would inherit its undocumented 1,800 s - default underneath a 60 m nextest budget, which is the inversion the + default underneath an 85 m nextest budget, which is the inversion the canonical section exists to prevent, so both halves are asserted: the action is not used, and the variable is not set. """ @@ -298,7 +298,7 @@ def test_the_cargo_watchdog_tier_is_absent_rather_than_defaulted() -> None: #: `docs/developers-guide.md` records it. Pinned as well as derived: the #: derivation accepts any ceiling above its requirement, so a value #: nobody chose passes it while drifting away from the guide. -REQUIRED_CEILING_MINUTES: typ.Final[int] = 100 +REQUIRED_CEILING_MINUTES: typ.Final[int] = 120 #: The condition each suite lane legitimately carries, keyed by workflow #: and job, as the step's ``if`` and its job's. @@ -326,8 +326,8 @@ def test_every_suite_lane_carries_the_documented_ceiling( The derivation asserts the ordering holds, and it holds for a range of ceilings, so a lane drifting to a value nobody chose still passes - it. This asserts the 100 minutes the developers' guide pins: a policy - value above the derived requirement of 91 minutes 5 seconds, which + it. This asserts the 120 minutes the developers' guide pins: a policy + value above the derived requirement of 116 minutes 5 seconds, which already carries the fifteen-minute margin. """ wrong = { diff --git a/typos.local.toml b/typos.local.toml index 3c27a55ef..5d83ef1df 100644 --- a/typos.local.toml +++ b/typos.local.toml @@ -74,9 +74,36 @@ ignore = [ "(?m)^error: Tha gnรฌomh std::fs `std::fs::read` aโ€™ seachnadh aโ€™ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", "(?m)^module_max_lines = Tha mรฒideal \\{ \\$module \\} aโ€™ leudachadh gu \\{ \\$lines \\} loidhnichean agus aโ€™ briseadh an crรฌoch \\{ \\$limit \\}\\.$", "(?m)^no_std_fs_operations = Tha gnรฌomh std::fs `\\{ \\$operation \\}` aโ€™ seachnadh aโ€™ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", - # Legacy public names remain only in compile-fail contracts. SARIF wire keys - # use the line-specific exceptions above so they cannot exempt prose. - "\\b(?:normalise_locale|rasterise_signal|normalise_settings|Artifact|ArtifactLocation|with_artifact|artifact_location)\\b", + # Preserve removed API names only on exact fixture and diagnostic lines. + # If a trybuild snapshot changes, update these patterns with the fixture. + '(?m)^use whitaker_common::complexity_signal::rasterise_signal;$', + '(?m)^use whitaker_common::normalise_locale;$', + '(?m)^ let _ = normalise_locale\(Some\("en-GB"\)\);$', + '(?m)^ let _ = rasterise_signal\(1\.\.=1, &\[\]\);$', + '(?m)^3 \| use whitaker_common::complexity_signal::rasterise_signal;$', + '(?m)^3 - use whitaker_common::complexity_signal::rasterise_signal;$', + '(?m)^4 \| use whitaker_common::normalise_locale;$', + '(?m)^4 - use whitaker_common::normalise_locale;$', + '(?m)^use bumpy_road_function::analysis::normalise_settings;$', + '(?m)^ let _ = normalise_settings\(Default::default\(\)\);$', + '(?m)^3 \| use bumpy_road_function::analysis::normalise_settings;$', + '(?m)^3 - use bumpy_road_function::analysis::normalise_settings;$', + '(?m)^use whitaker_sarif::\{Artifact, ArtifactLocation, RunBuilder\};$', + '(?m)^ let _ = RunBuilder::new\("whitaker", "0\.3\.0"\)\.with_artifact\(Artifact \{$', + '(?m)^ location: ArtifactLocation \{$', + '(?m)^3 \| use whitaker_sarif::\{Artifact, ArtifactLocation, RunBuilder\};$', + '(?m)^3 - use whitaker_sarif::\{Artifact, ArtifactLocation, RunBuilder\};$', + '(?m)^3 \+ use whitaker_sarif::\{Artefact, ArtifactLocation, RunBuilder\};$', + '(?m)^3 \+ use whitaker_sarif::\{Artifact, ArtefactLocation, RunBuilder\};$', + '(?m)^7 \| let _ = RunBuilder::new\("whitaker", "0\.3\.0"\)\.with_artifact\(Artifact \{$', + '(?m)^7 - let _ = RunBuilder::new\("whitaker", "0\.3\.0"\)\.with_artifact\(Artifact \{$', + '(?m)^7 \+ let _ = RunBuilder::new\("whitaker", "0\.3\.0"\)\.with_artefact\(Artifact \{$', + '(?m)^ artifact_location: ArtefactLocation \{$', + '(?m)^ let _ = RunBuilder::new\("whitaker", "0\.3\.0"\)\.with_artifact\(Artefact \{$', + '(?m)^8 \| artifact_location: ArtefactLocation \{$', + '(?m)^8 - artifact_location: ArtefactLocation \{$', + '(?m)^16 \| let _ = RunBuilder::new\("whitaker", "0\.3\.0"\)\.with_artifact\(Artefact \{$', + '(?m)^16 - let _ = RunBuilder::new\("whitaker", "0\.3\.0"\)\.with_artifact\(Artefact \{$', # Inline code spans are masked; the shared dictionary does not mask them yet. '`[^`\n]+`', ] diff --git a/typos.toml b/typos.toml index db47e92d0..0730eebab 100644 --- a/typos.toml +++ b/typos.toml @@ -40,8 +40,10 @@ extend-ignore-re = [ "(?m)^ json\\.contains\\(\"\\\\\"artifacts\\\\\"\"\\),$", "(?m)^ \\.env_remove\\(env::CARGO_TERM_COLOR\\)$", "(?m)^ \"\\-\\-artifact\\-server\\-path\",$", + "(?m)^ artifact_location: ArtefactLocation \\{$", "(?m)^ let Message::CompilerArtifact\\(artefact\\) = message else \\{$", "(?m)^ let Ok\\(Message::CompilerArtifact\\(artefact\\)\\) = message else \\{$", + "(?m)^ location: ArtifactLocation \\{$", "(?m)^ uses: actions/upload\\-artifact@v4$", "(?m)^ uses: actions/upload\\-artifact@v7$", "(?m)^ \\- Download all artefacts \\(actions/download\\-artifact@v4,$", @@ -62,9 +64,14 @@ extend-ignore-re = [ "(?m)^ \\.note = Tha na docs airson \\{ \\$test \\} aโ€™ gabhail a\\-steach ceann eisimpleirean no bloca cรฒd le feansa\\.$", "(?m)^ \\.note = Tha teachdaireachdan airson \\{ \\$lint \\} ri fhaighinn ann an Gร idhlig\\.$", "(?m)^ artefact: &cargo_metadata::Artifact,$", + "(?m)^ let _ = RunBuilder::new\\(\"whitaker\", \"0\\.3\\.0\"\\)\\.with_artifact\\(Artefact \\{$", + "(?m)^ let _ = RunBuilder::new\\(\"whitaker\", \"0\\.3\\.0\"\\)\\.with_artifact\\(Artifact \\{$", "(?m)^ let _ = fixture::categorise\\(0\\);$", "(?m)^ let _ = fixture::categorise\\(4\\);$", "(?m)^ let _ = fixture::categorise\\(99\\);$", + "(?m)^ let _ = normalise_locale\\(Some\\(\"en-GB\"\\)\\);$", + "(?m)^ let _ = normalise_settings\\(Default::default\\(\\)\\);$", + "(?m)^ let _ = rasterise_signal\\(1\\.\\.=1, &\\[\\]\\);$", "(?m)^ pub fn categorise\\(input: i32\\) \\-> &'static str \\{$", "(?m)^ types: \\[opened, reopened, synchronize, labeled, ready_for_review\\]$", "(?m)^ = help: Cuir seachad `cap_std::fs::Dir` agus paramadairean `camino::Utf8Path`/`Utf8PathBuf` tron API seach std::fs a ghairm gu dรฌreach\\.$", @@ -79,6 +86,23 @@ extend-ignore-re = [ "(?m)^## Tha `expect` toirmisgte taobh a\\-muigh deuchainnean\\.$", "(?m)^#\\. Air a shealltainn ann an breithneachaidhean nuair a tha e aโ€™ toirt iomradh air am buadh roimhe\\.$", "(?m)^.*run\\.artifacts;.*$", + "(?m)^16 - let _ = RunBuilder::new\\(\"whitaker\", \"0\\.3\\.0\"\\)\\.with_artifact\\(Artefact \\{$", + "(?m)^16 \\| let _ = RunBuilder::new\\(\"whitaker\", \"0\\.3\\.0\"\\)\\.with_artifact\\(Artefact \\{$", + "(?m)^3 - use bumpy_road_function::analysis::normalise_settings;$", + "(?m)^3 - use whitaker_common::complexity_signal::rasterise_signal;$", + "(?m)^3 - use whitaker_sarif::\\{Artifact, ArtifactLocation, RunBuilder\\};$", + "(?m)^3 \\+ use whitaker_sarif::\\{Artefact, ArtifactLocation, RunBuilder\\};$", + "(?m)^3 \\+ use whitaker_sarif::\\{Artifact, ArtefactLocation, RunBuilder\\};$", + "(?m)^3 \\| use bumpy_road_function::analysis::normalise_settings;$", + "(?m)^3 \\| use whitaker_common::complexity_signal::rasterise_signal;$", + "(?m)^3 \\| use whitaker_sarif::\\{Artifact, ArtifactLocation, RunBuilder\\};$", + "(?m)^4 - use whitaker_common::normalise_locale;$", + "(?m)^4 \\| use whitaker_common::normalise_locale;$", + "(?m)^7 - let _ = RunBuilder::new\\(\"whitaker\", \"0\\.3\\.0\"\\)\\.with_artifact\\(Artifact \\{$", + "(?m)^7 \\+ let _ = RunBuilder::new\\(\"whitaker\", \"0\\.3\\.0\"\\)\\.with_artefact\\(Artifact \\{$", + "(?m)^7 \\| let _ = RunBuilder::new\\(\"whitaker\", \"0\\.3\\.0\"\\)\\.with_artifact\\(Artifact \\{$", + "(?m)^8 - artifact_location: ArtefactLocation \\{$", + "(?m)^8 \\| artifact_location: ArtefactLocation \\{$", "(?m)^\\- Lexical segment matching can mis\\-handle renamed imports, glob imports, or$", "(?m)^\\- `\\-\\-artifact\\-server\\-path `: export uploaded artefacts to a host$", "(?m)^bumpy_road_function = Tha iomadh cruinneachadh de loidsig chumhachail neadaichte ann an `\\{ \\$name \\}`\\.$", @@ -88,6 +112,10 @@ extend-ignore-re = [ "(?m)^error: Tha gnรฌomh std::fs `std::fs::read` aโ€™ seachnadh aโ€™ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", "(?m)^module_max_lines = Tha mรฒideal \\{ \\$module \\} aโ€™ leudachadh gu \\{ \\$lines \\} loidhnichean agus aโ€™ briseadh an crรฌoch \\{ \\$limit \\}\\.$", "(?m)^no_std_fs_operations = Tha gnรฌomh std::fs `\\{ \\$operation \\}` aโ€™ seachnadh aโ€™ phoileasaidh comasan airson an t\\-siostaim fhaidhlichean\\.$", + "(?m)^use bumpy_road_function::analysis::normalise_settings;$", + "(?m)^use whitaker_common::complexity_signal::rasterise_signal;$", + "(?m)^use whitaker_common::normalise_locale;$", + "(?m)^use whitaker_sarif::\\{Artifact, ArtifactLocation, RunBuilder\\};$", "(?s)```.*?```", "--[A-Za-z0-9_-]*color(?:ed|s|ing|ize[ds]?|izing)?(?:\\b|_)", "Azure\\s+Architecture\\s+Center\\s*\\|\\s*Microsoft\\s+Learn\\b", @@ -97,7 +125,6 @@ extend-ignore-re = [ "\\[\"(?:artifactLocation|artifacts)\"\\]", "\\b(?:background|theme)_color\\b", "\\b(?:carousel|dropdown|footer|indicator|items|navbar|toast)-center\\b", - "\\b(?:normalise_locale|rasterise_signal|normalise_settings|Artifact|ArtifactLocation|with_artifact|artifact_location)\\b", "\\bAppFactory Date: Fri, 16 Oct 2026 09:49:49 +0200 Subject: [PATCH 26/32] Resolve installer maintainability findings Share dependency-query orchestration and split the installer and tracing-fixture helpers into focused pieces. Use finite fixture types for executable setup and document the ownership boundaries. --- docs/developers-guide.md | 6 + installer/src/error.rs | 39 +++-- installer/src/error_tests.rs | 9 +- installer/src/install_flow.rs | 27 +--- installer/src/main.rs | 10 +- installer/src/tests/mod.rs | 25 ++- installer/tests/tracing_cli.rs | 284 ++++++++++++++++++++++----------- 7 files changed, 268 insertions(+), 132 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index d6d16afcb..7349b1c62 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -2591,6 +2591,12 @@ duplicated across multiple test modules. The key helpers are: by `installer/src/tests.rs` to force the direct Cargo fallback path without network access. +The end-to-end tracing test in `installer/tests/tracing_cli.rs` keeps its +workspace, fake executables, and child environment private to that test module. +Those helpers model the test's controlled CLI failure and tracing contract; +they are not shared PATH utilities. Its `CliMode`, `DependencyPackage`, and +`FixtureExecutable` types represent only the finite choices used by this test. + ### CLI tool usage Release automation uses the `whitaker-package-dependency-binary` helper in diff --git a/installer/src/error.rs b/installer/src/error.rs index 6c2749634..353942652 100644 --- a/installer/src/error.rs +++ b/installer/src/error.rs @@ -211,13 +211,29 @@ pub enum InstallerError { }, } -// Helper to clone io::Error by preserving ErrorKind and formatted message. -// This is lossy: the original source chain is discarded. +/// Clone an I/O error while preserving its kind and formatted message. +/// +/// The clone is lossy because the original source chain is discarded. fn clone_io_error(source: &std::io::Error) -> std::io::Error { std::io::Error::new(source.kind(), source.to_string()) } impl InstallerError { + /// Clone the I/O-backed variants while preserving their error details. + fn clone_io_variant(&self) -> Option { + match self { + Self::Io(source) => Some(Self::Io(clone_io_error(source))), + Self::ScanFailed { source } => Some(Self::ScanFailed { + source: clone_io_error(source), + }), + Self::WriteFailed { source } => Some(Self::WriteFailed { + source: clone_io_error(source), + }), + _ => None, + } + } + + /// Clone the toolchain variants while preserving their semantic details. fn clone_toolchain_variant(&self) -> Option { match self { Self::ToolchainDetection { reason } => Some(Self::ToolchainDetection { @@ -254,7 +270,10 @@ impl InstallerError { impl Clone for InstallerError { fn clone(&self) -> Self { - if let Some(value) = self.clone_toolchain_variant() { + if let Some(value) = self + .clone_toolchain_variant() + .or_else(|| self.clone_io_variant()) + { return value; } @@ -285,7 +304,6 @@ impl Clone for InstallerError { path: path.clone(), reason: reason.clone(), }, - Self::Io(source) => Self::Io(clone_io_error(source)), Self::Git { operation, message } => Self::Git { operation, message: message.clone(), @@ -306,19 +324,16 @@ impl Clone for InstallerError { reason: reason.clone(), }, Self::WrapperGeneration(message) => Self::WrapperGeneration(message.clone()), - Self::ScanFailed { source } => Self::ScanFailed { - source: clone_io_error(source), - }, - Self::WriteFailed { source } => Self::WriteFailed { - source: clone_io_error(source), - }, Self::ToolchainDetection { .. } | Self::ToolchainFileNotFound { .. } | Self::InvalidToolchainFile { .. } | Self::ToolchainNotInstalled { .. } | Self::ToolchainInstallFailed { .. } - | Self::ToolchainComponentInstallFailed { .. } => { - unreachable!("handled by clone_toolchain_variant") + | Self::ToolchainComponentInstallFailed { .. } + | Self::Io(_) + | Self::ScanFailed { .. } + | Self::WriteFailed { .. } => { + unreachable!("handled by a clone helper") } #[cfg(any(test, feature = "test-support"))] Self::StubMismatch { message } => Self::StubMismatch { diff --git a/installer/src/error_tests.rs b/installer/src/error_tests.rs index 56ca2d4fe..0411d9821 100644 --- a/installer/src/error_tests.rs +++ b/installer/src/error_tests.rs @@ -133,7 +133,7 @@ fn source_fallback_forbidden_carries_the_artefact_and_the_reason() { } #[test] -fn the_policy_errors_survive_a_clone() { +fn policy_and_io_errors_survive_a_clone() { // `InstallerError::clone` matches every variant by hand, so a new variant // that nobody added an arm for is a panic waiting for the first caller. for err in [ @@ -144,6 +144,13 @@ fn the_policy_errors_survive_a_clone() { artefact: "a prebuilt lint library".to_owned(), reason: "repository asset not found".to_owned(), }, + InstallerError::Io(std::io::Error::other("standalone I/O error")), + InstallerError::ScanFailed { + source: std::io::Error::other("scan I/O error"), + }, + InstallerError::WriteFailed { + source: std::io::Error::other("write I/O error"), + }, ] { assert_eq!(err.clone().to_string(), err.to_string()); } diff --git a/installer/src/install_flow.rs b/installer/src/install_flow.rs index 0ec234f18..2e2c6c36e 100644 --- a/installer/src/install_flow.rs +++ b/installer/src/install_flow.rs @@ -13,9 +13,7 @@ use std::time::Duration; use whitaker_installer::builder::{library_extension, library_prefix}; use whitaker_installer::cli::InstallArgs; use whitaker_installer::crate_name::CrateName; -use whitaker_installer::deps::{CommandExecutor, SourcePolicy, install_dylint_tools_with_output}; -#[cfg(test)] -use whitaker_installer::deps::{DependencyInstallOptions, install_dylint_tools_with_options}; +use whitaker_installer::deps::{CommandExecutor, DylintToolStatus, SourcePolicy}; use whitaker_installer::dirs::BaseDirs; use whitaker_installer::error::{InstallerError, Result}; use whitaker_installer::install_metrics::{InstallMode, RecordOutcome, record_install}; @@ -25,7 +23,7 @@ use whitaker_installer::prebuilt_path::prebuilt_library_dir; use whitaker_installer::resolution::{EXPERIMENTAL_LINT_CRATES, LINT_CRATES, SUITE_CRATE}; mod dependency_check; -#[cfg(test)] +#[cfg(all(test, unix))] pub(crate) use dependency_check::check_dylint_tools_for_command; pub(crate) fn ensure_dylint_tools_core( @@ -52,30 +50,21 @@ pub(crate) fn ensure_dylint_tools_core( Ok(()) } +/// Query availability once, then delegate missing-tool installation. +/// +/// The callback keeps query ordering shared by the CLI and tests while +/// leaving the concrete installation boundary injectable. pub(crate) fn ensure_dylint_tools_with_executor( executor: &dyn CommandExecutor, policy: SourcePolicy, stderr: &mut dyn Write, + install: impl FnOnce(&DylintToolStatus, &mut dyn Write) -> Result<()>, ) -> Result<()> { dependency_check::ensure_dylint_tools_with_query( policy.quiet, stderr, || dependency_check::check_dylint_tools_for_command(executor), - |status, stderr| install_dylint_tools_with_output(executor, status, policy, stderr), - ) -} - -#[cfg(test)] -pub(crate) fn ensure_dylint_tools_with_options( - executor: &dyn CommandExecutor, - stderr: &mut dyn Write, - options: DependencyInstallOptions<'_>, -) -> Result<()> { - dependency_check::ensure_dylint_tools_with_query( - options.policy.quiet, - stderr, - || dependency_check::check_dylint_tools_for_command(executor), - |status, stderr| install_dylint_tools_with_options(executor, status, stderr, options), + install, ) } diff --git a/installer/src/main.rs b/installer/src/main.rs index ce7a14f68..8eb9feed7 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -8,8 +8,6 @@ mod install_flow; mod staged_suite; mod tracing_config; -#[cfg(test)] -use crate::install_flow::ensure_dylint_tools_with_options; use crate::install_flow::{ MetricsWriteContext, PrebuiltInstallationContext, ensure_dylint_tools_with_executor, try_prebuilt_installation, write_install_metrics, @@ -20,7 +18,9 @@ use std::io::Write; use std::time::Instant; use whitaker_installer::cli::{Cli, Command, InstallArgs}; use whitaker_installer::crate_name::CrateName; -use whitaker_installer::deps::{SourcePolicy, SystemCommandExecutor}; +use whitaker_installer::deps::{ + SourcePolicy, SystemCommandExecutor, install_dylint_tools_with_output, +}; use whitaker_installer::dirs::{BaseDirs, SystemBaseDirs}; use whitaker_installer::error::{InstallerError, Result}; use whitaker_installer::install_metrics::InstallMode; @@ -197,7 +197,9 @@ use dry_run::run_dry; /// Checks for and installs Dylint tools if missing. fn ensure_dylint_tools(policy: SourcePolicy, stderr: &mut dyn Write) -> Result<()> { let executor = SystemCommandExecutor; - ensure_dylint_tools_with_executor(&executor, policy, stderr) + ensure_dylint_tools_with_executor(&executor, policy, stderr, |status, stderr| { + install_dylint_tools_with_output(&executor, status, policy, stderr) + }) } /// Ensures a Whitaker workspace is available. diff --git a/installer/src/tests/mod.rs b/installer/src/tests/mod.rs index ad090b630..6dcb1dbd3 100644 --- a/installer/src/tests/mod.rs +++ b/installer/src/tests/mod.rs @@ -8,7 +8,7 @@ use rstest::{fixture, rstest}; use whitaker_installer::{ cli::InstallArgs, dependency_binaries::DependencyBinaryInstaller, - deps::{DependencyInstallOptions, SourcePolicy}, + deps::{DependencyInstallOptions, SourcePolicy, install_dylint_tools_with_options}, dirs::BaseDirs, installer_packaging::TargetTriple, test_utils::{ @@ -139,7 +139,12 @@ fn ensure_dylint_tools_skips_install_when_installed( let mut stderr = Vec::new(); let options = dependency_install_options(&test_base_dirs, &repository_installer, false) .expect("dependency install options should build"); - let result = ensure_dylint_tools_with_options(&executor, &mut stderr, options); + let result = ensure_dylint_tools_with_executor( + &executor, + options.policy, + &mut stderr, + |status, stderr| install_dylint_tools_with_options(&executor, status, stderr, options), + ); assert!(result.is_ok()); assert!(stderr.is_empty()); @@ -183,7 +188,12 @@ fn ensure_dylint_tools_installs_missing_tools( let mut stderr = Vec::new(); let options = dependency_install_options(&test_base_dirs, &repository_installer, quiet) .expect("dependency install options should build"); - let result = ensure_dylint_tools_with_options(&executor, &mut stderr, options); + let result = ensure_dylint_tools_with_executor( + &executor, + options.policy, + &mut stderr, + |status, stderr| install_dylint_tools_with_options(&executor, status, stderr, options), + ); assert!(result.is_ok()); let stderr_text = String::from_utf8(stderr).expect("stderr was not UTF-8"); @@ -232,8 +242,13 @@ fn ensure_dylint_tools_propagates_install_failures( let mut stderr = Vec::new(); let options = dependency_install_options(&test_base_dirs, &repository_installer, false) .expect("dependency install options should build"); - let err = ensure_dylint_tools_with_options(&executor, &mut stderr, options) - .expect_err("expected install failure"); + let err = ensure_dylint_tools_with_executor( + &executor, + options.policy, + &mut stderr, + |status, stderr| install_dylint_tools_with_options(&executor, status, stderr, options), + ) + .expect_err("expected install failure"); assert!(matches!( err, diff --git a/installer/tests/tracing_cli.rs b/installer/tests/tracing_cli.rs index be504c949..b617c58f2 100644 --- a/installer/tests/tracing_cli.rs +++ b/installer/tests/tracing_cli.rs @@ -2,7 +2,7 @@ #![cfg(unix)] -use camino::Utf8PathBuf; +use camino::{Utf8Path, Utf8PathBuf}; use cap_std::{ ambient_authority, fs_utf8::{Dir, Permissions, PermissionsExt}, @@ -22,6 +22,69 @@ struct TracingFixture { environment: Vec<(OsString, OsString)>, } +/// Select one supported installer CLI verbosity mode for the fixture. +#[derive(Clone, Copy)] +enum CliMode { + Default, + Verbose, + Quiet, +} + +impl CliMode { + /// Return the arguments that select this mode. + fn arguments(self) -> &'static [&'static str] { + match self { + Self::Default => &[], + Self::Verbose => &["-v"], + Self::Quiet => &["-q"], + } + } + + /// Return the short label used in assertion messages. + fn label(self) -> &'static str { + match self { + Self::Default => "default", + Self::Verbose => "verbose", + Self::Quiet => "quiet", + } + } + + /// Report whether this mode enables debug diagnostics. + fn is_verbose(self) -> bool { + matches!(self, Self::Verbose) + } +} + +/// Identify one pinned installer dependency without passing package strings. +#[derive(Clone, Copy)] +enum DependencyPackage { + CargoDylint, + DylintLink, +} + +impl DependencyPackage { + /// Return this dependency's embedded manifest key. + fn manifest_name(self) -> &'static str { + match self { + Self::CargoDylint => "cargo-dylint", + Self::DylintLink => "dylint-link", + } + } +} + +/// Hold the pinned versions used to synthesize fake Cargo output. +struct DependencyVersions { + cargo_dylint: &'static str, + dylint_link: &'static str, +} + +/// Select a fake executable whose behaviour is controlled by this fixture. +enum FixtureExecutable { + Cargo(DependencyVersions), + DylintLink, + Rustup, +} + impl TracingFixture { /// Create temporary binaries and a minimal current-directory workspace. /// @@ -40,73 +103,9 @@ impl TracingFixture { let root_dir = Dir::open_ambient_dir(&root_path, ambient_authority())?; let workspace = root_path.join("workspace"); let binary_directory = root_path.join("bin"); - root_dir.create_dir("workspace")?; - root_dir.create_dir("bin")?; - root_dir.write("not-a-directory", b"PATH entry is a file")?; - let workspace_dir = root_dir.open_dir("workspace")?; - let binary_dir = root_dir.open_dir("bin")?; - workspace_dir.write( - "Cargo.toml", - "[package]\nname = \"whitaker\"\nversion = \"0.0.0\"\nedition = \"2024\"\n", - )?; - let cargo_version = dependency_version("cargo-dylint")?; - let link_version = dependency_version("dylint-link")?; - write_executable( - &binary_dir, - "cargo", - &fake_cargo_script(cargo_version, link_version), - )?; - write_executable(&binary_dir, "dylint-link", "#!/bin/sh\nexit 0\n")?; - write_executable( - &binary_dir, - "rustup", - "#!/bin/sh\necho 'controlled rustup failure' >&2\nexit 1\n", - )?; - - for directory in ["data", "config", "cache", "rustup", "cargo-home"] { - root_dir.create_dir(directory)?; - } - - let data_home = root_path.join("data"); - let config_home = root_path.join("config"); - let cache_home = root_path.join("cache"); - let rustup_home = root_path.join("rustup"); - let cargo_home = root_path.join("cargo-home"); - let invalid_path_entry = root_path.join("not-a-directory"); - let path = std::env::join_paths([ - invalid_path_entry.as_std_path().as_os_str(), - binary_directory.as_std_path().as_os_str(), - ])?; - let mut environment = vec![ - (OsString::from("PATH"), path), - ( - OsString::from("HOME"), - root_path.as_std_path().as_os_str().to_owned(), - ), - ( - OsString::from("XDG_DATA_HOME"), - data_home.as_std_path().as_os_str().to_owned(), - ), - ( - OsString::from("XDG_CONFIG_HOME"), - config_home.as_std_path().as_os_str().to_owned(), - ), - ( - OsString::from("XDG_CACHE_HOME"), - cache_home.as_std_path().as_os_str().to_owned(), - ), - ( - OsString::from("RUSTUP_HOME"), - rustup_home.as_std_path().as_os_str().to_owned(), - ), - ( - OsString::from("CARGO_HOME"), - cargo_home.as_std_path().as_os_str().to_owned(), - ), - ]; - if let Some(ld_library_path) = std::env::var_os("LD_LIBRARY_PATH") { - environment.push((OsString::from("LD_LIBRARY_PATH"), ld_library_path)); - } + create_workspace(&root_dir)?; + create_binaries(&root_dir)?; + let environment = create_environment(&root_dir, &root_path, &binary_directory)?; Ok(Self { _root: root, @@ -117,6 +116,99 @@ impl TracingFixture { } } +/// Create the minimal workspace passed to the installer child process. +/// +/// # Errors +/// +/// Returns a filesystem error if the workspace directory or manifest cannot +/// be created. +fn create_workspace(root_dir: &Dir) -> io::Result<()> { + root_dir.create_dir("workspace")?; + root_dir.open_dir("workspace")?.write( + "Cargo.toml", + "[package]\nname = \"whitaker\"\nversion = \"0.0.0\"\nedition = \"2024\"\n", + ) +} + +/// Create fake dependency binaries and the controlled PATH file entry. +/// +/// # Errors +/// +/// Returns an error if dependency versions cannot be read or fixture files +/// cannot be created. +fn create_binaries(root_dir: &Dir) -> Result<(), Box> { + root_dir.create_dir("bin")?; + root_dir.write("not-a-directory", b"PATH entry is a file")?; + let binary_dir = root_dir.open_dir("bin")?; + let versions = DependencyVersions { + cargo_dylint: dependency_version(DependencyPackage::CargoDylint)?, + dylint_link: dependency_version(DependencyPackage::DylintLink)?, + }; + write_executable(&binary_dir, FixtureExecutable::Cargo(versions))?; + write_executable(&binary_dir, FixtureExecutable::DylintLink)?; + write_executable(&binary_dir, FixtureExecutable::Rustup)?; + Ok(()) +} + +/// Assemble isolated paths and environment variables for child invocations. +/// +/// # Errors +/// +/// Returns an error if a home directory cannot be created or the child PATH +/// cannot be assembled. +fn create_environment( + root_dir: &Dir, + root_path: &Utf8Path, + binary_directory: &Utf8Path, +) -> Result, Box> { + for directory in ["data", "config", "cache", "rustup", "cargo-home"] { + root_dir.create_dir(directory)?; + } + + let data_home = root_path.join("data"); + let config_home = root_path.join("config"); + let cache_home = root_path.join("cache"); + let rustup_home = root_path.join("rustup"); + let cargo_home = root_path.join("cargo-home"); + let invalid_path_entry = root_path.join("not-a-directory"); + let path = std::env::join_paths([ + invalid_path_entry.as_std_path().as_os_str(), + binary_directory.as_std_path().as_os_str(), + ])?; + let mut environment = vec![ + (OsString::from("PATH"), path), + ( + OsString::from("HOME"), + root_path.as_std_path().as_os_str().to_owned(), + ), + ( + OsString::from("XDG_DATA_HOME"), + data_home.as_std_path().as_os_str().to_owned(), + ), + ( + OsString::from("XDG_CONFIG_HOME"), + config_home.as_std_path().as_os_str().to_owned(), + ), + ( + OsString::from("XDG_CACHE_HOME"), + cache_home.as_std_path().as_os_str().to_owned(), + ), + ( + OsString::from("RUSTUP_HOME"), + rustup_home.as_std_path().as_os_str().to_owned(), + ), + ( + OsString::from("CARGO_HOME"), + cargo_home.as_std_path().as_os_str().to_owned(), + ), + ]; + if let Some(ld_library_path) = std::env::var_os("LD_LIBRARY_PATH") { + environment.push((OsString::from("LD_LIBRARY_PATH"), ld_library_path)); + } + + Ok(environment) +} + /// Exercise the real binary's verbosity parsing and tracing initialization. /// /// # Errors @@ -125,13 +217,9 @@ impl TracingFixture { #[test] fn main_filters_path_scan_diagnostics_by_cli_verbosity() -> Result<(), Box> { let fixture = TracingFixture::new()?; - for (mode, arguments, is_verbose) in [ - ("default", &[][..], false), - ("verbose", &["-v"][..], true), - ("quiet", &["-q"][..], false), - ] { - let output = run_cli_mode(&fixture, arguments)?; - assert_cli_output(&output, &fixture, mode, is_verbose); + for mode in [CliMode::Default, CliMode::Verbose, CliMode::Quiet] { + let output = run_cli_mode(&fixture, mode)?; + assert_cli_output(&output, &fixture, mode); } Ok(()) } @@ -141,10 +229,10 @@ fn main_filters_path_scan_diagnostics_by_cli_verbosity() -> Result<(), Box io::Result { +fn run_cli_mode(fixture: &TracingFixture, mode: CliMode) -> io::Result { Command::new(env!("CARGO_BIN_EXE_whitaker-installer")) .args(["--toolchain", "nightly-test"]) - .args(arguments) + .args(mode.arguments()) .current_dir(fixture.workspace.as_std_path()) .env_clear() .envs(fixture.environment.iter().cloned()) @@ -152,34 +240,36 @@ fn run_cli_mode(fixture: &TracingFixture, arguments: &[&str]) -> io::Result String { +fn fake_cargo_script(versions: DependencyVersions) -> String { format!( concat!( "#!/bin/sh\n", @@ -200,7 +290,7 @@ fn fake_cargo_script(cargo_version: &str, link_version: &str) -> String { " *) echo 'unexpected fake cargo invocation' >&2; exit 97 ;;\n", "esac\n", ), - cargo_version, cargo_version, link_version, + versions.cargo_dylint, versions.cargo_dylint, versions.dylint_link, ) } @@ -209,12 +299,16 @@ fn fake_cargo_script(cargo_version: &str, link_version: &str) -> String { /// # Errors /// /// Returns an error if the manifest cannot be parsed or omits the package. -fn dependency_version(package: &str) -> Result<&'static str, Box> { - let dependency = whitaker_installer::dependency_binaries::find_dependency_binary(package)? - .ok_or_else(|| { +fn dependency_version(package: DependencyPackage) -> Result<&'static str, Box> { + let dependency = + whitaker_installer::dependency_binaries::find_dependency_binary(package.manifest_name())? + .ok_or_else(|| { io::Error::new( io::ErrorKind::NotFound, - format!("embedded dependency manifest must include {package}"), + format!( + "embedded dependency manifest must include {}", + package.manifest_name() + ), ) })?; Ok(dependency.version()) @@ -226,7 +320,15 @@ fn dependency_version(package: &str) -> Result<&'static str, Box> { /// /// Returns a filesystem error if the script or its executable permissions /// cannot be written. -fn write_executable(directory: &Dir, path: &str, contents: &str) -> io::Result<()> { - directory.write(path, contents)?; +fn write_executable(directory: &Dir, executable: FixtureExecutable) -> io::Result<()> { + let (path, contents) = match executable { + FixtureExecutable::Cargo(versions) => ("cargo", fake_cargo_script(versions)), + FixtureExecutable::DylintLink => ("dylint-link", String::from("#!/bin/sh\nexit 0\n")), + FixtureExecutable::Rustup => ( + "rustup", + String::from("#!/bin/sh\necho 'controlled rustup failure' >&2\nexit 1\n"), + ), + }; + directory.write(path, &contents)?; directory.set_permissions(path, Permissions::from_mode(0o755)) } From 01f4b869742d70135a4a41854328b7b7fbbbfd65 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 16 Oct 2026 11:44:38 +0200 Subject: [PATCH 27/32] Split source-policy cloning from `Clone::clone` Extract the source-policy variants into a dedicated helper so the large-method check stays below its configured threshold while preserving the existing cloned error details. --- installer/src/error.rs | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/installer/src/error.rs b/installer/src/error.rs index 353942652..c91a706b2 100644 --- a/installer/src/error.rs +++ b/installer/src/error.rs @@ -219,6 +219,22 @@ fn clone_io_error(source: &std::io::Error) -> std::io::Error { } impl InstallerError { + /// Clone the source-policy variants while preserving their policy details. + fn clone_source_policy_variant(&self) -> Option { + match self { + Self::ConflictingSourceOptions { option } => Some(Self::ConflictingSourceOptions { + option: option.clone(), + }), + Self::SourceFallbackForbidden { artefact, reason } => { + Some(Self::SourceFallbackForbidden { + artefact: artefact.clone(), + reason: reason.clone(), + }) + } + _ => None, + } + } + /// Clone the I/O-backed variants while preserving their error details. fn clone_io_variant(&self) -> Option { match self { @@ -273,6 +289,7 @@ impl Clone for InstallerError { if let Some(value) = self .clone_toolchain_variant() .or_else(|| self.clone_io_variant()) + .or_else(|| self.clone_source_policy_variant()) { return value; } @@ -316,15 +333,10 @@ impl Clone for InstallerError { tool, reason: reason.clone(), }, - Self::ConflictingSourceOptions { option } => Self::ConflictingSourceOptions { - option: option.clone(), - }, - Self::SourceFallbackForbidden { artefact, reason } => Self::SourceFallbackForbidden { - artefact: artefact.clone(), - reason: reason.clone(), - }, Self::WrapperGeneration(message) => Self::WrapperGeneration(message.clone()), - Self::ToolchainDetection { .. } + Self::ConflictingSourceOptions { .. } + | Self::SourceFallbackForbidden { .. } + | Self::ToolchainDetection { .. } | Self::ToolchainFileNotFound { .. } | Self::InvalidToolchainFile { .. } | Self::ToolchainNotInstalled { .. } From dfbdc297e4b4ae361698dd35666ff836131fe3ec Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 17 Oct 2026 08:57:25 +0200 Subject: [PATCH 28/32] Harden installer and compile-contract checks Treat vanished PATH directories as ordinary misses while retaining failure categories for other open errors. Add compile-time checks for the installer dependency-query API and its removed legacy surface. Give all discovered nested trybuild contracts a shared serial 45-minute allowance in both nextest profiles, and verify that the workflow contract matches the configured test set. --- .config/nextest.toml | 61 +++++---- docs/developers-guide.md | 75 ++++++---- docs/whitaker-dylint-suite-design.md | 12 ++ installer/src/deps/mod.rs | 34 ++++- installer/src/deps/path_tests.rs | 23 ++++ installer/tests/ui.rs | 11 +- .../legacy_dylint_dependency_check_status.rs | 14 ++ ...gacy_dylint_dependency_check_status.stderr | 14 ++ .../tests/ui/legacy_installer_error_match.rs | 37 +++++ .../ui/legacy_installer_error_match.stderr | 20 +++ .../ui/pass_dylint_dependency_check_api.rs | 47 +++++++ .../compile_contract_allowance_test.py | 2 +- .../nextest_profile_test.py | 129 ++++++++++-------- 13 files changed, 369 insertions(+), 110 deletions(-) create mode 100644 installer/tests/ui/legacy_dylint_dependency_check_status.rs create mode 100644 installer/tests/ui/legacy_dylint_dependency_check_status.stderr create mode 100644 installer/tests/ui/legacy_installer_error_match.rs create mode 100644 installer/tests/ui/legacy_installer_error_match.stderr create mode 100644 installer/tests/ui/pass_dylint_dependency_check_api.rs diff --git a/.config/nextest.toml b/.config/nextest.toml index b21252076..e3a06aba9 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -43,31 +43,39 @@ test-group = "serial-toolchain-installs" [[profile.ci.overrides]] # Dylint UI harnesses build lint libraries; the default profile gives them -# the same allowance below. `trybuild` compile-contract binaries belong -# here too: they invoke a nested cargo build, so they cost what a build -# costs rather than what a test costs. `whitaker-installer`'s binary is +# the same allowance below. This selector also matches named trybuild +# contracts, whose dedicated 45-minute override below takes precedence. +# `whitaker-installer`'s binary is # named `ui` but its test is not, so the `(binary(ui) & test(=ui))` # clause does not reach it; it measured 235 s and timed out at 300 s on # the Windows lane under the base allowance. A full workspace run also # reached 9m56 in the trybuild harness while compiler admission queued. # Keep that nested build bounded while leaving room to finish under contention. -filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(trybuild_fixtures_compile_without_diagnostics) | test(normalize_settings_is_public_and_the_legacy_name_stays_removed) | test(renamed_common_apis_are_public_and_legacy_names_stay_removed) | test(renamed_sarif_api_is_public_and_legacy_names_stay_removed) | (binary(ui) & test(=ui))" +filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(trybuild_fixtures_compile_without_diagnostics) | test(renamed_common_apis_are_public_and_legacy_names_stay_removed) | test(renamed_sarif_api_is_public_and_legacy_names_stay_removed) | (binary(ui) & test(=ui))" slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } test-group = "serial-dylint-ui" [[profile.ci.overrides]] -# The macro crate's trybuild contract builds fixture crates too, so it needs a -# build-sized timeout and the same serial target-directory protection. -filter = "test(fixture_expansion_lint_attribute_contract)" -slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } +# Every discovered trybuild test builds fixtures through nested Cargo. Keep +# them under one build-sized allowance and avoid retries that repeat the work. +filter = "test(dependency_check_result_contract_is_public) | test(fixture_expansion_lint_attribute_contract) | test(normalize_settings_is_public_and_the_legacy_name_stays_removed) | test(renamed_common_apis_are_public_and_legacy_names_stay_removed) | test(renamed_sarif_api_is_public_and_legacy_names_stay_removed) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(trybuild_fixtures_compile_without_diagnostics)" +slow-timeout = { period = "45m", terminate-after = 1, grace-period = "5s" } test-group = "serial-dylint-ui" +retries = 0 [[profile.ci.overrides]] -# The parser-pin cases run nested Cargo checks in isolated workspaces and need -# the longer allowance while compiler admission queues. +# The parser-pin fixtures run nested Cargo builds with isolated targets. Their +# longer allowance reflects a full interval spent queued for compiler slots. filter = "binary(build_script_integration)" -slow-timeout = { period = "30m", terminate-after = 1, grace-period = "5s" } -test-group = "serial-build-script-integration" +slow-timeout = { period = "45m", terminate-after = 1, grace-period = "5s" } +test-group = "serial-nested-cargo-builds" + +[[profile.ci.overrides]] +# This feature-disabled build also uses a fresh target directory; allow it to +# finish after nested Cargo waits for shared compiler admission. +filter = "binary(no_default_features_build)" +slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } +test-group = "serial-nested-cargo-builds" # Serialize dylint UI tests that build lint libraries and use # `#[serial]`. nextest runs each test in its own process, so the @@ -77,7 +85,7 @@ test-group = "serial-build-script-integration" [test-groups] serial-dylint-ui = { max-threads = 1 } serial-toolchain-installs = { max-threads = 1 } -serial-build-script-integration = { max-threads = 1 } +serial-nested-cargo-builds = { max-threads = 1 } [[profile.default.overrides]] # Include crate-local UI entrypoints that wrap dylint UI fixtures. @@ -112,25 +120,32 @@ serial-build-script-integration = { max-threads = 1 } # Windows CI regardless of whether any example source uses rstest, so all # Dylint example compilation for that crate is compiled away on Windows. # The trybuild harness reached 9m56 in a full run while compiler admission -# queued, so allow 20 minutes for the complete serial UI group. -filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(example_compiles_without_diagnostics) | test(example_harness_collects_call_site_evidence) | test(trybuild_fixtures_compile_without_diagnostics) | test(normalize_settings_is_public_and_the_legacy_name_stays_removed) | test(renamed_common_apis_are_public_and_legacy_names_stay_removed) | test(renamed_sarif_api_is_public_and_legacy_names_stay_removed) | test(ui::example_compiles_under_test_harness) | test(ui::hand_written_test_companion_does_not_exempt_parent_function) | test(ui::rstest_unwrap_outside_tests_still_fails_in_non_harness_code) | test(ui::rstest_empty_companion_does_not_exempt_parent_function) | test(ui::aliased_test_crate_non_companion_does_not_exempt_parent_function) | (binary(ui) & test(=ui))" +# queued, so allow 20 minutes for the ordinary serial UI group. Compile +# contracts have a separate longer allowance below. +filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(example_compiles_without_diagnostics) | test(example_harness_collects_call_site_evidence) | test(trybuild_fixtures_compile_without_diagnostics) | test(renamed_common_apis_are_public_and_legacy_names_stay_removed) | test(renamed_sarif_api_is_public_and_legacy_names_stay_removed) | test(ui::example_compiles_under_test_harness) | test(ui::hand_written_test_companion_does_not_exempt_parent_function) | test(ui::rstest_unwrap_outside_tests_still_fails_in_non_harness_code) | test(ui::rstest_empty_companion_does_not_exempt_parent_function) | test(ui::aliased_test_crate_non_companion_does_not_exempt_parent_function) | (binary(ui) & test(=ui))" test-group = "serial-dylint-ui" retries = { backoff = "exponential", count = 2, delay = "5s" } slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } [[profile.default.overrides]] -# The macro crate's trybuild contract builds fixture crates too, so it needs a -# build-sized timeout and the same serial target-directory protection. -filter = "test(fixture_expansion_lint_attribute_contract)" -slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } +# Every discovered trybuild test builds fixtures through nested Cargo. Keep +# them under one build-sized allowance and avoid retries that repeat the work. +filter = "test(dependency_check_result_contract_is_public) | test(fixture_expansion_lint_attribute_contract) | test(normalize_settings_is_public_and_the_legacy_name_stays_removed) | test(renamed_common_apis_are_public_and_legacy_names_stay_removed) | test(renamed_sarif_api_is_public_and_legacy_names_stay_removed) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(trybuild_fixtures_compile_without_diagnostics)" +slow-timeout = { period = "45m", terminate-after = 1, grace-period = "5s" } test-group = "serial-dylint-ui" +retries = 0 [[profile.default.overrides]] -# These parser-pin integration cases run nested Cargo checks in isolated -# workspaces; a cold build may queue behind other admitted compiler work. +# These parser-pin and no-default-feature cases run nested Cargo builds in +# isolated targets; a cold build may queue behind admitted compiler work. filter = "binary(build_script_integration)" -slow-timeout = { period = "30m", terminate-after = 1, grace-period = "5s" } -test-group = "serial-build-script-integration" +slow-timeout = { period = "45m", terminate-after = 1, grace-period = "5s" } +test-group = "serial-nested-cargo-builds" + +[[profile.default.overrides]] +filter = "binary(no_default_features_build)" +slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } +test-group = "serial-nested-cargo-builds" [[profile.default.overrides]] # Serialize ignored exclusion integration tests when they are explicitly run. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 7349b1c62..5f47e4f3f 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -3622,6 +3622,22 @@ crate consumes the same HIR lowering policy. Until then, keep compiler-aware HIR logic in the lint crate and keep the pure `ArgAtom`/`ArgFingerprint` model in `whitaker_common::rstest`. +### Numeric and span bounds in complexity diagnostics + +The Bumpy Road and conditional-branch lints convert counts at the boundary +between analysis and compiler diagnostics. Their `diagnostic_integer_argument` +and `saturating_diagnostic_integer` helpers clamp Fluent integer arguments to +`i64::MAX` rather than wrapping. Bumpy Road's `SegmentWeight::Fixed` keeps its +supplied value; `SegmentWeight::Predicate` uses `saturating_branch_count` and +`predicate_segment_weight` to cap branch counts at `u32::MAX` before scaling. + +Rust compiler `BytePos` offsets are also `u32`. `span_byte_offset_range` +rejects a highlighted source range if either endpoint exceeds that limit; the +lint omits the secondary span label and still emits the primary diagnostic. +Tests cover the maximum accepted value and the first value above each boundary +in `crates/bumpy_road_function/src/driver/numeric.rs` and +`crates/conditional_max_n_branches/src/driver/numeric.rs`. + ### Adding a new lint New lints should typically start as experimental. To add a lint: @@ -3977,12 +3993,12 @@ of the rule lives in the `generate-coverage` README in this repository sets, what each value is sized against, and where the third tier went. -| Tier | What it bounds | Where it is set | Current value | -| ------------------------ | ---------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Per-test `slow-timeout` | one test | `.config/nextest.toml`, both profiles | 300 s default; 20 m for serialized Dylint/trybuild UI harnesses; 30 m for serialized nested-Cargo fixtures; 30 m for toolchain-installing behaviour tests | -| nextest `global-timeout` | the whole test run | `.config/nextest.toml`, both profiles | 85 m | -| Cargo watchdog | one `cargo` invocation, wall clock | not used here, see below | absent | -| Job `timeout-minutes` | the whole job | `ci.yml` and `coverage-main.yml`, job level | 120 m for every lane that runs the suite | +| Tier | What it bounds | Where it is set | Current value | +| ------------------------ | ---------------------------------- | ------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Per-test `slow-timeout` | one test | `.config/nextest.toml`, both profiles | 300 s default; 20 m for ordinary serialized UI and no-default-feature builds; 45 m for parser-pin and trybuild compile-contract fixtures; 30 m for toolchain-installing behaviour tests | +| nextest `global-timeout` | the whole test run | `.config/nextest.toml`, both profiles | 85 m | +| Cargo watchdog | one `cargo` invocation, wall clock | not used here, see below | absent | +| Job `timeout-minutes` | the whole job | `ci.yml` and `coverage-main.yml`, job level | 120 m for every lane that runs the suite | *Table: the timers that can end a run, innermost first.* @@ -4025,28 +4041,36 @@ where a reader of that profile will find them. Restating them also makes a later divergence between the two profiles explicit rather than silent, since a changed default would otherwise change CI without touching the `ci` section. -The parser-pin build-script tests each create an isolated nested Cargo -workspace with its own target directory. Their 10 m allowance accommodates cold -builds queued for compiler admission after one exceeded the 300 s base timeout; -it does not serialize the tests. +The no-default-feature regression test checks the library in a nested Cargo +invocation with an isolated target directory, so it rebuilds dependencies +instead of reusing artefacts from the workspace test run. It exceeded the +300-second base allowance while compiler admission was heavily queued. The +20-minute allowance and shared nested-Cargo test group leave room for a cold +build without letting it compete with the parser-pin fixtures. The contract holds that policy, and its failure messages say so rather than claiming nextest requires it. -The serial UI group has a 20-minute bound. A full workspace run reached 9m56 in -the nested trybuild harness while compiler admission queued; the wider -allowance lets a contended compile finish while still terminating a stalled -test. - -The three parser-pin build-script integration cases also receive a 30-minute -allowance and run in a dedicated serial test group. Each case invokes nested -Cargo against a fresh temporary target directory, so it rebuilds the fixture's -build dependencies. The exact-pin case performs both `cargo check` and -`cargo run`; a traced run completed in 591 seconds after waiting on the shared -package-cache lock. The loose-pin case was still compiling when its 15-minute -allowance expired under shared compiler admission. The 30-minute allowance -leaves room for that queue without changing the whole-suite budget. Serializing -these cases keeps their Cargo invocations from queueing against one another. +The ordinary serial UI group has a 20-minute bound. A full workspace run +reached 9m56 in the nested trybuild harness while compiler admission queued; +the wider allowance lets a contended compile finish while still terminating a +stalled test. + +Every trybuild compile contract rebuilds fixture crates through nested Cargo. +In a cold workspace, the Bumpy Road public API contract timed out on three +20-minute attempts; the final attempt reached its pass fixture after 15 minutes +of nested compilation. Retrying restarted that expensive build without +producing a result. All discovered compile contracts therefore share a +45-minute allowance and disable retries, leaving one build enough time to reach +its fixtures without repeating the same work. + +The three parser-pin build-script integration cases run in the same serial +nested-Cargo group and each use a fresh temporary target directory. The +exact-pin case performs both `cargo check` and `cargo run`; a traced run +completed in 591 seconds after waiting on the shared package-cache lock. A +later full run exhausted the 30-minute allowance while compiler admission had a +large queue. The 45-minute allowance reflects that observed wait, and the group +prevents these fixtures from competing with the no-default-feature build. ### The clocks do not start together @@ -4249,7 +4273,8 @@ failure and `tests/nextest_ui_filter.rs` requires the key. It discovers the compile-contract tests rather than listing them. A test that drives `trybuild` invokes a nested cargo build, so it costs what a build costs and the base per-test allowance is the wrong order of magnitude for it. The -current discovery finds six tests across `common/tests/public_api_contract.rs`, +current discovery finds seven tests across +`common/tests/public_api_contract.rs`, `crates/bumpy_road_function/tests/api_contract.rs`, `crates/whitaker_sarif/tests/api_contract.rs`, `installer/tests/ui.rs`, `crates/whitaker_test_macros/tests/ui.rs`, and diff --git a/docs/whitaker-dylint-suite-design.md b/docs/whitaker-dylint-suite-design.md index 1fa19edf4..4ad975763 100644 --- a/docs/whitaker-dylint-suite-design.md +++ b/docs/whitaker-dylint-suite-design.md @@ -1290,6 +1290,18 @@ highlight the top two intervals in the diagnostic. - Include the lint in the aggregated `whitaker_suite` crate by default as a stable rule, while still allowing direct usage by loading the lint crate. +**Numeric and span boundaries.** Fluent diagnostic integer arguments are signed +64-bit values, so `diagnostic_integer_argument` and +`saturating_diagnostic_integer` clamp oversized counts at `i64::MAX`. +`SegmentWeight::Fixed` keeps its supplied value, while +`SegmentWeight::Predicate` uses `saturating_branch_count` and +`predicate_segment_weight` to cap branch counts at `u32::MAX` before scaling. +Compiler `BytePos` offsets are `u32`; `span_byte_offset_range` rejects any +highlighted range with an unrepresentable endpoint, omitting its secondary +label while preserving the primary diagnostic. The conditional-branch lint uses +the same signed boundary for Fluent counts. Boundary tests cover the maximum +accepted values and the first values above them. + **Algorithm sketch.** 1. Walk the function HIR, updating depth and collecting segments for blocks, diff --git a/installer/src/deps/mod.rs b/installer/src/deps/mod.rs index 60eced4d0..336271e4f 100644 --- a/installer/src/deps/mod.rs +++ b/installer/src/deps/mod.rs @@ -142,6 +142,11 @@ fn metadata_failure_category(kind: io::ErrorKind) -> Option Option { + (kind != io::ErrorKind::NotFound).then_some(PathScanFailureCategory::DirectoryOpen) +} + /// Status of Dylint tool availability. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct DylintToolStatus { @@ -543,11 +548,13 @@ fn find_binary_in_directory( }; let directory_capability = match Dir::open_ambient_dir(utf8_directory, ambient_authority()) { Ok(directory_capability) => directory_capability, - Err(_) => { - failures.push(PathScanFailure { - binary_name: binary_name.to_owned(), - category: PathScanFailureCategory::DirectoryOpen, - }); + Err(error) => { + if let Some(category) = directory_open_failure_category(error.kind()) { + failures.push(PathScanFailure { + binary_name: binary_name.to_owned(), + category, + }); + } return None; } }; @@ -681,7 +688,9 @@ mod tests; mod path_scan_tests { //! Verify that PATH scan failure categories remain stable and bounded. - use super::{PathScanFailureCategory, metadata_failure_category}; + use super::{ + PathScanFailureCategory, directory_open_failure_category, metadata_failure_category, + }; /// Treat an absent PATH candidate as routine while preserving other failures. #[test] @@ -696,6 +705,19 @@ mod path_scan_tests { ); } + /// Treat disappeared PATH directories as misses while preserving other errors. + #[test] + fn directory_open_failures_exclude_missing_directories() { + assert_eq!( + directory_open_failure_category(std::io::ErrorKind::NotFound), + None + ); + assert_eq!( + directory_open_failure_category(std::io::ErrorKind::PermissionDenied), + Some(PathScanFailureCategory::DirectoryOpen) + ); + } + /// Keep path-scan failure category labels stable and bounded. #[test] fn path_scan_failure_categories_are_stable_and_bounded() { diff --git a/installer/src/deps/path_tests.rs b/installer/src/deps/path_tests.rs index a797f9f99..f98ea8e62 100644 --- a/installer/src/deps/path_tests.rs +++ b/installer/src/deps/path_tests.rs @@ -319,6 +319,29 @@ fn path_scan_skips_missing_candidates_and_returns_the_first_binary() -> std::io: Ok(()) } +/// Skip a PATH directory that disappeared and continue to a later executable. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixtures cannot be created. +#[test] +fn path_scan_skips_missing_directories_and_returns_a_later_binary() -> std::io::Result<()> { + let fixture_root = tempfile::tempdir()?; + let missing_directory = fixture_root.path().join("removed-from-path"); + let valid_directory = tempfile::tempdir()?; + let expected_binary = valid_directory.path().join("dylint-link"); + write_fake_binary(&expected_binary, true)?; + + let scan = scan_path_directories( + vec![missing_directory, valid_directory.path().to_path_buf()], + "dylint-link", + ); + + assert_eq!(scan.binary, Some(expected_binary)); + assert!(scan.failures.is_empty()); + Ok(()) +} + /// Check first-match and stop-after-match behaviour for bounded PATH patterns. #[test] fn path_scan_ordering_holds_for_bounded_match_patterns() { diff --git a/installer/tests/ui.rs b/installer/tests/ui.rs index 5bf326536..3b0d41317 100644 --- a/installer/tests/ui.rs +++ b/installer/tests/ui.rs @@ -1,4 +1,4 @@ -//! Compile-fail guards for the `sha2` 0.11 migration. +//! Compile-time guards for installer compatibility contracts. //! //! `sha2` 0.11 made two source-breaking changes that the installer had to work //! around: @@ -18,3 +18,12 @@ fn sha2_0_11_pre_migration_patterns_fail_to_compile() { cases.compile_fail("tests/ui/digest_lowerhex.rs"); cases.compile_fail("tests/ui/hasher_io_write.rs"); } + +/// Keep the public dependency-query result and error variant usable downstream. +#[test] +fn dependency_check_result_contract_is_public() { + let cases = trybuild::TestCases::new(); + cases.pass("tests/ui/pass_dylint_dependency_check_api.rs"); + cases.compile_fail("tests/ui/legacy_dylint_dependency_check_status.rs"); + cases.compile_fail("tests/ui/legacy_installer_error_match.rs"); +} diff --git a/installer/tests/ui/legacy_dylint_dependency_check_status.rs b/installer/tests/ui/legacy_dylint_dependency_check_status.rs new file mode 100644 index 000000000..00f1ebcda --- /dev/null +++ b/installer/tests/ui/legacy_dylint_dependency_check_status.rs @@ -0,0 +1,14 @@ +//! Compile-fail guard for the former bare-status return contract. + +use whitaker_installer::deps::{check_dylint_tools, CommandExecutor, DylintToolStatus}; + +/// Require the removed bare-status result type. +fn legacy_check(executor: &dyn CommandExecutor) { + let status: DylintToolStatus = check_dylint_tools(executor); + let _ = status; +} + +/// Keep the legacy API check reachable during compilation. +fn main() { + let _ = legacy_check; +} diff --git a/installer/tests/ui/legacy_dylint_dependency_check_status.stderr b/installer/tests/ui/legacy_dylint_dependency_check_status.stderr new file mode 100644 index 000000000..47c2beedb --- /dev/null +++ b/installer/tests/ui/legacy_dylint_dependency_check_status.stderr @@ -0,0 +1,14 @@ +error[E0308]: mismatched types + --> tests/ui/legacy_dylint_dependency_check_status.rs:7:36 + | +7 | let status: DylintToolStatus = check_dylint_tools(executor); + | ---------------- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `DylintToolStatus`, found `Result` + | | + | expected due to this + | + = note: expected struct `DylintToolStatus` + found enum `Result` +help: consider using `Result::expect` to unwrap the `Result` value, panicking if the value is a `Result::Err` + | +7 | let status: DylintToolStatus = check_dylint_tools(executor).expect("REASON"); + | +++++++++++++++++ diff --git a/installer/tests/ui/legacy_installer_error_match.rs b/installer/tests/ui/legacy_installer_error_match.rs new file mode 100644 index 000000000..185bf5a04 --- /dev/null +++ b/installer/tests/ui/legacy_installer_error_match.rs @@ -0,0 +1,37 @@ +//! Compile-fail guard for consumers that do not handle dependency-check errors. + +use whitaker_installer::error::InstallerError; + +/// Match every pre-dependency-check error variant exhaustively. +fn legacy_error_kind(error: &InstallerError) -> &'static str { + match error { + InstallerError::ConflictingSourceOptions { .. } => "source policy", + InstallerError::SourceFallbackForbidden { .. } => "source fallback", + InstallerError::ToolchainDetection { .. } => "toolchain detection", + InstallerError::ToolchainFileNotFound { .. } => "toolchain file", + InstallerError::InvalidToolchainFile { .. } => "toolchain file", + InstallerError::ToolchainNotInstalled { .. } => "toolchain install", + InstallerError::ToolchainInstallFailed { .. } => "toolchain install", + InstallerError::ToolchainComponentInstallFailed { .. } => "toolchain component", + InstallerError::BuildFailed { .. } => "build", + InstallerError::StagingFailed { .. } => "staging", + InstallerError::StubMismatch { .. } => "stub mismatch", + InstallerError::TargetNotWritable { .. } => "target directory", + InstallerError::LintCrateNotFound { .. } => "lint crate", + InstallerError::ExperimentalLintRequiresFlag { .. } => "experimental lint", + InstallerError::WorkspaceNotFound { .. } => "workspace", + InstallerError::SuitePinInWorkspace { .. } => "suite pin", + InstallerError::InvalidCargoToml { .. } => "manifest", + InstallerError::Io(_) => "I/O", + InstallerError::Git { .. } => "Git", + InstallerError::DependencyInstall { .. } => "dependency install", + InstallerError::WrapperGeneration(_) => "wrapper", + InstallerError::ScanFailed { .. } => "scan", + InstallerError::WriteFailed { .. } => "write", + } +} + +/// Keep the legacy exhaustive match reachable during compilation. +fn main() { + let _ = legacy_error_kind; +} diff --git a/installer/tests/ui/legacy_installer_error_match.stderr b/installer/tests/ui/legacy_installer_error_match.stderr new file mode 100644 index 000000000..7403efee9 --- /dev/null +++ b/installer/tests/ui/legacy_installer_error_match.stderr @@ -0,0 +1,20 @@ +error[E0004]: non-exhaustive patterns: `&InstallerError::DependencyCheck { .. }` not covered + --> tests/ui/legacy_installer_error_match.rs:7:11 + | + 7 | match error { + | ^^^^^ pattern `&InstallerError::DependencyCheck { .. }` not covered + | +note: `InstallerError` defined here + --> src/error.rs + | + | pub enum InstallerError { + | ^^^^^^^^^^^^^^^^^^^^^^^ +... + | DependencyCheck { + | --------------- not covered + = note: the matched value is of type `&InstallerError` +help: ensure that all possible cases are being handled by adding a match arm with a wildcard pattern or an explicit pattern as shown + | +30 ~ InstallerError::WriteFailed { .. } => "write", +31 ~ &InstallerError::DependencyCheck { .. } => todo!(), + | diff --git a/installer/tests/ui/pass_dylint_dependency_check_api.rs b/installer/tests/ui/pass_dylint_dependency_check_api.rs new file mode 100644 index 000000000..71bc81ce2 --- /dev/null +++ b/installer/tests/ui/pass_dylint_dependency_check_api.rs @@ -0,0 +1,47 @@ +//! Compile-pass guard for the public installer dependency-check API. + +use whitaker_installer::{ + deps::{check_dylint_tools, CommandExecutor, DylintToolStatus}, + error::{InstallerError, Result}, +}; + +/// Return the dependency-query result through its public API. +fn check(executor: &dyn CommandExecutor) -> Result { + check_dylint_tools(executor) +} + +/// Match every public installer error, including dependency checks. +fn error_kind(error: &InstallerError) -> &'static str { + match error { + InstallerError::ConflictingSourceOptions { .. } => "source policy", + InstallerError::SourceFallbackForbidden { .. } => "source fallback", + InstallerError::ToolchainDetection { .. } => "toolchain detection", + InstallerError::ToolchainFileNotFound { .. } => "toolchain file", + InstallerError::InvalidToolchainFile { .. } => "toolchain file", + InstallerError::ToolchainNotInstalled { .. } => "toolchain install", + InstallerError::ToolchainInstallFailed { .. } => "toolchain install", + InstallerError::ToolchainComponentInstallFailed { .. } => "toolchain component", + InstallerError::BuildFailed { .. } => "build", + InstallerError::StagingFailed { .. } => "staging", + InstallerError::StubMismatch { .. } => "stub mismatch", + InstallerError::TargetNotWritable { .. } => "target directory", + InstallerError::LintCrateNotFound { .. } => "lint crate", + InstallerError::ExperimentalLintRequiresFlag { .. } => "experimental lint", + InstallerError::WorkspaceNotFound { .. } => "workspace", + InstallerError::SuitePinInWorkspace { .. } => "suite pin", + InstallerError::InvalidCargoToml { .. } => "manifest", + InstallerError::Io(_) => "I/O", + InstallerError::Git { .. } => "Git", + InstallerError::DependencyInstall { .. } => "dependency install", + InstallerError::DependencyCheck { .. } => "dependency check", + InstallerError::WrapperGeneration(_) => "wrapper", + InstallerError::ScanFailed { .. } => "scan", + InstallerError::WriteFailed { .. } => "write", + } +} + +/// Exercise the public result and exhaustive error match without a host command. +fn main() { + let _ = check; + let _ = error_kind; +} diff --git a/tests/workflow_contracts/compile_contract_allowance_test.py b/tests/workflow_contracts/compile_contract_allowance_test.py index d0342db7b..71b1a81a9 100644 --- a/tests/workflow_contracts/compile_contract_allowance_test.py +++ b/tests/workflow_contracts/compile_contract_allowance_test.py @@ -27,7 +27,7 @@ from ubicloud_workflow_support import REPOSITORY_ROOT #: Bound for compile-contract harnesses under shared compiler admission. -COMPILE_CONTRACT_TIMEOUT: typ.Final[str] = "20m" +COMPILE_CONTRACT_TIMEOUT: typ.Final[str] = "45m" class RustSourceError(OSError): diff --git a/tests/workflow_contracts/nextest_profile_test.py b/tests/workflow_contracts/nextest_profile_test.py index 92edc02fe..41dec74f8 100644 --- a/tests/workflow_contracts/nextest_profile_test.py +++ b/tests/workflow_contracts/nextest_profile_test.py @@ -43,9 +43,19 @@ #: The whole-run budget both profiles must declare. REQUIRED_GLOBAL_TIMEOUT: typ.Final[str] = "85m" -# Parser-pin fixtures share one integration binary, target directory and Cargo -# package cache, so the allowance must cover that whole binary and serialize it. +# Parser-pin fixtures share one integration binary and Cargo package cache, so +# the allowance must cover that whole binary and serialize it. BUILD_SCRIPT_INTEGRATION_FILTER: typ.Final[str] = "binary(build_script_integration)" +NO_DEFAULT_FEATURES_FILTER: typ.Final[str] = "binary(no_default_features_build)" +COMPILE_CONTRACT_FILTER: typ.Final[str] = ( + "test(dependency_check_result_contract_is_public) | " + "test(fixture_expansion_lint_attribute_contract) | " + "test(normalize_settings_is_public_and_the_legacy_name_stays_removed) | " + "test(renamed_common_apis_are_public_and_legacy_names_stay_removed) | " + "test(renamed_sarif_api_is_public_and_legacy_names_stay_removed) | " + "test(sha2_0_11_pre_migration_patterns_fail_to_compile) | " + "test(trybuild_fixtures_compile_without_diagnostics)" +) class Allowance(typ.NamedTuple): @@ -57,20 +67,18 @@ class Allowance(typ.NamedTuple): The whole ``slow-timeout`` table the override must declare. test_group : str or None The group it must name, or None when it names none. The budget - and the group are one claim: a test allowed thirty minutes but - no longer serialized runs concurrently with its siblings on the - shared target directory, which is what the group exists to stop. - retries : dict[str, object] or None - The whole ``retries`` table, or None when the override declares - none. Part of the same claim: the Dylint UI harnesses retry a - transient Windows rename failure, and `tests/nextest_ui_filter.rs` - requires the key, so a change to its count, backoff or delay - would otherwise pass every assertion here. + and group are one claim: nested Cargo builds with long allowances + share one serial group so cold fixtures do not compete for admission. + retries : integer, dict[str, object], or None + The whole ``retries`` setting, or None when the override declares + none. The Dylint UI harnesses retry transient Windows rename failures; + Compile contracts disable retries because each retry repeats a + costly nested Cargo build. """ slow_timeout: dict[str, object] test_group: str | None - retries: dict[str, object] | None = None + retries: int | dict[str, object] | None = None #: The overrides both profiles carry, keyed by the binary or filter they @@ -83,23 +91,38 @@ class Allowance(typ.NamedTuple): #: cover it. #: #: The filters are stated whole and matched exactly, per profile. A -#: substring would accept a narrowed one: dropping a clause from the -#: `ui` disjunction leaves the needle intact while the tests that clause -#: named lose the ten-minute allowance and fall back to the base three -#: hundred seconds. The two profiles genuinely differ here, the default one -#: naming seven more `test(...)` clauses in its shared UI override than -#: `ci`: fifteen against eight, with `(binary(ui) & test(=ui))` shared and -#: counted in neither. The macro crate's compile contract and parser-pin -#: nested Cargo checks have their own identical allowances in both profiles. +#: substring would accept a narrowed one: dropping a clause from a `ui` +#: disjunction leaves each selected needle intact while the affected tests +#: lose the 20-minute allowance and fall back to the base 300 seconds. The +#: default profile includes platform-specific example tests that `ci` omits, +#: but both profiles give expensive API contracts and nested Cargo fixtures +#: their own explicit allowances. REQUIRED_OVERRIDES: typ.Final[dict[str, dict[str, Allowance]]] = { "default": { BUILD_SCRIPT_INTEGRATION_FILTER: Allowance( slow_timeout={ - "period": "30m", + "period": "45m", + "terminate-after": 1, + "grace-period": "5s", + }, + test_group="serial-nested-cargo-builds", + ), + NO_DEFAULT_FEATURES_FILTER: Allowance( + slow_timeout={ + "period": "20m", "terminate-after": 1, "grace-period": "5s", }, - test_group="serial-build-script-integration", + test_group="serial-nested-cargo-builds", + ), + COMPILE_CONTRACT_FILTER: Allowance( + slow_timeout={ + "period": "45m", + "terminate-after": 1, + "grace-period": "5s", + }, + test_group="serial-dylint-ui", + retries=0, ), ("binary(behaviour_toolchain)"): Allowance( slow_timeout={ @@ -119,8 +142,6 @@ class Allowance(typ.NamedTuple): "test(example_compiles_without_diagnostics) | " "test(example_harness_collects_call_site_evidence) | " "test(trybuild_fixtures_compile_without_diagnostics) | " - "test(normalize_settings_is_public_and_the_legacy_name_stays_" - "removed) | " "test(renamed_common_apis_are_public_and_legacy_names_stay_" "removed) | " "test(renamed_sarif_api_is_public_and_legacy_names_stay_" @@ -144,23 +165,32 @@ class Allowance(typ.NamedTuple): test_group="serial-dylint-ui", retries={"backoff": "exponential", "count": 2, "delay": "5s"}, ), - "test(fixture_expansion_lint_attribute_contract)": Allowance( + }, + "ci": { + BUILD_SCRIPT_INTEGRATION_FILTER: Allowance( + slow_timeout={ + "period": "45m", + "terminate-after": 1, + "grace-period": "5s", + }, + test_group="serial-nested-cargo-builds", + ), + NO_DEFAULT_FEATURES_FILTER: Allowance( slow_timeout={ "period": "20m", "terminate-after": 1, "grace-period": "5s", }, - test_group="serial-dylint-ui", + test_group="serial-nested-cargo-builds", ), - }, - "ci": { - BUILD_SCRIPT_INTEGRATION_FILTER: Allowance( + COMPILE_CONTRACT_FILTER: Allowance( slow_timeout={ - "period": "30m", + "period": "45m", "terminate-after": 1, "grace-period": "5s", }, - test_group="serial-build-script-integration", + test_group="serial-dylint-ui", + retries=0, ), ("binary(behaviour_toolchain)"): Allowance( slow_timeout={ @@ -176,8 +206,6 @@ class Allowance(typ.NamedTuple): "test(ui::ui) | " "test(sha2_0_11_pre_migration_patterns_fail_to_compile) | " "test(trybuild_fixtures_compile_without_diagnostics) | " - "test(normalize_settings_is_public_and_the_legacy_name_stays_" - "removed) | " "test(renamed_common_apis_are_public_and_legacy_names_stay_" "removed) | " "test(renamed_sarif_api_is_public_and_legacy_names_stay_" @@ -191,14 +219,6 @@ class Allowance(typ.NamedTuple): }, test_group="serial-dylint-ui", ), - "test(fixture_expansion_lint_attribute_contract)": Allowance( - slow_timeout={ - "period": "20m", - "terminate-after": 1, - "grace-period": "5s", - }, - test_group="serial-dylint-ui", - ), }, } @@ -206,12 +226,12 @@ class Allowance(typ.NamedTuple): #: A group a profile names and ``[test-groups]`` does not declare is a #: configuration nextest refuses, and one declared without #: ``max-threads = 1`` serializes nothing: the UI harnesses would then -#: build lint libraries concurrently against the shared target -#: directory, which is the race the groups exist to stop. +#: build lint libraries concurrently against the shared target directory, +#: while nested Cargo fixtures would compete for compiler admission. REQUIRED_TEST_GROUPS: typ.Final[dict[str, dict[str, object]]] = { "serial-dylint-ui": {"max-threads": 1}, "serial-toolchain-installs": {"max-threads": 1}, - "serial-build-script-integration": {"max-threads": 1}, + "serial-nested-cargo-builds": {"max-threads": 1}, } @@ -294,16 +314,16 @@ def test_each_profile_states_the_overrides_its_own_tests_need( than silent. Each policy key is compared as a whole table, so the grace period is - asserted alongside the period and the multiplier, and the retry - backoff and delay alongside the count. Checking - the period alone would let the grace period be dropped, and the + asserted alongside the period and the multiplier, and the retry policy + alongside its count, backoff and delay. Compile contracts explicitly + disable retries because each attempt repeats a costly nested build. + Checking only the period would let the grace period be dropped, and the watchdog above these budgets is sized to cover exactly that wait. The filter is compared whole and exactly, per profile. A substring - would accept a narrowed filter: dropping a clause from the `ui` - disjunction leaves any needle intact while the tests that clause - named lose the ten-minute allowance and fall back to the base three - hundred seconds. + would accept a narrowed filter: dropping a clause from a `ui` + disjunction leaves each selected needle intact while the affected tests + lose the 20-minute allowance and fall back to the base 300 seconds. Only overrides that declare a budget are counted. The default profile matches ``binary(behaviour_toolchain)`` twice, once for the @@ -333,9 +353,10 @@ def test_each_profile_states_the_overrides_its_own_tests_need( ) assert matching[0].get("retries") == allowance.retries, ( f"[profile.{profile}]'s {needle!r} override must declare retries " - f"{allowance.retries}, got {matching[0].get('retries')}; the Dylint UI " - f"harnesses retry a transient Windows rename failure, and " - f"`tests/nextest_ui_filter.rs` requires the key" + f"{allowance.retries}, got {matching[0].get('retries')}; Dylint UI " + f"harnesses retain retries for transient Windows rename failures, " + f"while the compile-contract override disables retries; " + f"`tests/nextest_ui_filter.rs` also requires the UI retry policy" ) From 9eb7a5c52bc2e61adc8187b23937e57e3a17e940 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 17 Oct 2026 09:39:36 +0200 Subject: [PATCH 29/32] Fix Windows PATH scan fixture Use an explicit `.exe` fixture when testing PATH continuation. Windows expands extensionless names through `PATHEXT`, so the previous fixture did not match any candidate. Keep this test focused on skipping a missing directory and finding the later binary. --- installer/src/deps/path_tests.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/installer/src/deps/path_tests.rs b/installer/src/deps/path_tests.rs index f98ea8e62..8f2d74df0 100644 --- a/installer/src/deps/path_tests.rs +++ b/installer/src/deps/path_tests.rs @@ -329,12 +329,13 @@ fn path_scan_skips_missing_directories_and_returns_a_later_binary() -> std::io:: let fixture_root = tempfile::tempdir()?; let missing_directory = fixture_root.path().join("removed-from-path"); let valid_directory = tempfile::tempdir()?; - let expected_binary = valid_directory.path().join("dylint-link"); + let binary_name = "dylint-link.exe"; + let expected_binary = valid_directory.path().join(binary_name); write_fake_binary(&expected_binary, true)?; let scan = scan_path_directories( vec![missing_directory, valid_directory.path().to_path_buf()], - "dylint-link", + binary_name, ); assert_eq!(scan.binary, Some(expected_binary)); From 3eec56dde4675c97f0f1f821f8c66ac6d305e3ca Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 17 Oct 2026 11:21:05 +0200 Subject: [PATCH 30/32] Harden installer validation and gate contracts Delay install success output until companion status refresh completes, propagate tracing setup failures, and cover installer query errors. Align nextest timeout profiles with their workflow contract and document numeric offset edge cases. --- .config/nextest.toml | 14 +- docs/developers-guide.md | 36 ++- docs/whitaker-dylint-suite-design.md | 24 +- installer/src/deps/install.rs | 64 ++--- installer/src/deps/install_failure_tests.rs | 228 ++++++++++++++++++ installer/src/deps/tests.rs | 3 + installer/src/main.rs | 8 +- installer/src/tracing_config.rs | 61 ++++- .../nextest_profile_test.py | 12 - 9 files changed, 379 insertions(+), 71 deletions(-) create mode 100644 installer/src/deps/install_failure_tests.rs diff --git a/.config/nextest.toml b/.config/nextest.toml index e3a06aba9..c68dca64f 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -43,15 +43,14 @@ test-group = "serial-toolchain-installs" [[profile.ci.overrides]] # Dylint UI harnesses build lint libraries; the default profile gives them -# the same allowance below. This selector also matches named trybuild -# contracts, whose dedicated 45-minute override below takes precedence. +# the same allowance below. Compile-contract tests use the dedicated +# 45-minute override below. # `whitaker-installer`'s binary is # named `ui` but its test is not, so the `(binary(ui) & test(=ui))` # clause does not reach it; it measured 235 s and timed out at 300 s on # the Windows lane under the base allowance. A full workspace run also -# reached 9m56 in the trybuild harness while compiler admission queued. # Keep that nested build bounded while leaving room to finish under contention. -filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(trybuild_fixtures_compile_without_diagnostics) | test(renamed_common_apis_are_public_and_legacy_names_stay_removed) | test(renamed_sarif_api_is_public_and_legacy_names_stay_removed) | (binary(ui) & test(=ui))" +filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | (binary(ui) & test(=ui))" slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } test-group = "serial-dylint-ui" @@ -119,10 +118,9 @@ serial-nested-cargo-builds = { max-threads = 1 } # dev-dependency proc-macro hangs during cargo's dev-dependency resolution on # Windows CI regardless of whether any example source uses rstest, so all # Dylint example compilation for that crate is compiled away on Windows. -# The trybuild harness reached 9m56 in a full run while compiler admission -# queued, so allow 20 minutes for the ordinary serial UI group. Compile -# contracts have a separate longer allowance below. -filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(sha2_0_11_pre_migration_patterns_fail_to_compile) | test(example_compiles_without_diagnostics) | test(example_harness_collects_call_site_evidence) | test(trybuild_fixtures_compile_without_diagnostics) | test(renamed_common_apis_are_public_and_legacy_names_stay_removed) | test(renamed_sarif_api_is_public_and_legacy_names_stay_removed) | test(ui::example_compiles_under_test_harness) | test(ui::hand_written_test_companion_does_not_exempt_parent_function) | test(ui::rstest_unwrap_outside_tests_still_fails_in_non_harness_code) | test(ui::rstest_empty_companion_does_not_exempt_parent_function) | test(ui::aliased_test_crate_non_companion_does_not_exempt_parent_function) | (binary(ui) & test(=ui))" +# The installer UI case measured 235 s and timed out at the 300 s base +# allowance on Windows. Allow nested UI builds to finish while keeping a cap. +filter = "test(driver::ui::) | test(tests::ui::) | test(ui::ui) | test(example_compiles_without_diagnostics) | test(example_harness_collects_call_site_evidence) | test(ui::example_compiles_under_test_harness) | test(ui::hand_written_test_companion_does_not_exempt_parent_function) | test(ui::rstest_unwrap_outside_tests_still_fails_in_non_harness_code) | test(ui::rstest_empty_companion_does_not_exempt_parent_function) | test(ui::aliased_test_crate_non_companion_does_not_exempt_parent_function) | (binary(ui) & test(=ui))" test-group = "serial-dylint-ui" retries = { backoff = "exponential", count = 2, delay = "5s" } slow-timeout = { period = "20m", terminate-after = 1, grace-period = "5s" } diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 5f47e4f3f..35ab73668 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -3632,10 +3632,28 @@ supplied value; `SegmentWeight::Predicate` uses `saturating_branch_count` and `predicate_segment_weight` to cap branch counts at `u32::MAX` before scaling. Rust compiler `BytePos` offsets are also `u32`. `span_byte_offset_range` -rejects a highlighted source range if either endpoint exceeds that limit; the -lint omits the secondary span label and still emits the primary diagnostic. -Tests cover the maximum accepted value and the first value above each boundary -in `crates/bumpy_road_function/src/driver/numeric.rs` and +rejects a highlighted source range if either endpoint exceeds that limit; +`line_range_byte_offsets` also rejects a start line before the snippet's base +line, a reversed range, or a start line missing from the snippet's line table. +When the requested end line has no following line-start entry, the mapper uses +the snippet length as the end offset. If either resulting byte offset cannot be +represented as `u32`, the mapper omits the secondary span label while still +emitting the primary diagnostic. Tests cover the maximum accepted value and the +first value above each boundary in +`crates/bumpy_road_function/src/driver/numeric.rs` and +`crates/conditional_max_n_branches/src/driver/numeric.rs`. + +Production tests that pass a value beyond a `usize` boundary are gated by +`#[cfg(target_pointer_width = "64")]`; 32-bit targets cannot represent those +inputs. The cases are `diagnostic_arguments_saturate_oversized_bump_counts` and +`span_mapping_omits_unrepresentable_offsets` in +`crates/bumpy_road_function/src/driver/diagnostic.rs`, plus +`production_diagnostic_arguments_saturate_at_the_signed_boundary`, +`production_predicate_weight_saturates_excess_branches`, +`production_span_offset_ranges_reject_an_unrepresentable_end`, and +`production_line_range_mapping_rejects_an_unrepresentable_offset` in its +`numeric.rs`. The conditional-branch boundary case is +`production_diagnostic_arguments_saturate_at_the_signed_boundary` in `crates/conditional_max_n_branches/src/driver/numeric.rs`. ### Adding a new lint @@ -4051,10 +4069,10 @@ build without letting it compete with the parser-pin fixtures. The contract holds that policy, and its failure messages say so rather than claiming nextest requires it. -The ordinary serial UI group has a 20-minute bound. A full workspace run -reached 9m56 in the nested trybuild harness while compiler admission queued; -the wider allowance lets a contended compile finish while still terminating a -stalled test. +The ordinary serial UI group has a 20-minute bound. The installer UI case +measured 235 seconds and timed out at the 300-second base allowance on Windows. +The wider allowance lets nested UI builds finish under contention while still +terminating a stalled test. Every trybuild compile contract rebuilds fixture crates through nested Cargo. In a cold workspace, the Bumpy Road public API contract timed out on three @@ -4115,7 +4133,7 @@ The 85 m whole-run budget is sized against the latest uncached full run. It reached 1,840 of 1,842 tests before the global timeout stopped the SARIF API contract's nested trybuild compilation while Cargo waited on shared locks and compiler admission. The additional 10 m gives that contract and the remaining -test room to finish without weakening the 30 m per-test ceiling. +test room to finish without weakening the configured per-test ceilings. The 15 m allowance for work outside nextest's window is measured, from the worst of several runs rather than one: diff --git a/docs/whitaker-dylint-suite-design.md b/docs/whitaker-dylint-suite-design.md index 4ad975763..7ab9e8989 100644 --- a/docs/whitaker-dylint-suite-design.md +++ b/docs/whitaker-dylint-suite-design.md @@ -1297,10 +1297,26 @@ highlight the top two intervals in the diagnostic. `SegmentWeight::Predicate` uses `saturating_branch_count` and `predicate_segment_weight` to cap branch counts at `u32::MAX` before scaling. Compiler `BytePos` offsets are `u32`; `span_byte_offset_range` rejects any -highlighted range with an unrepresentable endpoint, omitting its secondary -label while preserving the primary diagnostic. The conditional-branch lint uses -the same signed boundary for Fluent counts. Boundary tests cover the maximum -accepted values and the first values above them. +highlighted range with an unrepresentable endpoint. `line_range_byte_offsets` +also rejects starts before the snippet's base line, reversed ranges, and starts +missing from its line table; when the end line has no following entry, it uses +the snippet length as the end offset. A rejected or unrepresentable range omits +the secondary label while preserving the primary diagnostic. The +conditional-branch lint uses the same signed boundary for Fluent counts. +Boundary tests cover the maximum accepted values and the first values above +them. + +Production tests that pass values beyond a `usize` boundary use +`#[cfg(target_pointer_width = "64")]`; 32-bit targets cannot represent those +inputs. Bumpy Road tests `diagnostic_arguments_saturate_oversized_bump_counts` +and `span_mapping_omits_unrepresentable_offsets` in `driver/diagnostic.rs`, plus +`production_diagnostic_arguments_saturate_at_the_signed_boundary`, +`production_predicate_weight_saturates_excess_branches`, +`production_span_offset_ranges_reject_an_unrepresentable_end`, and +`production_line_range_mapping_rejects_an_unrepresentable_offset` in +`driver/numeric.rs`. The conditional-branch test is +`production_diagnostic_arguments_saturate_at_the_signed_boundary` in its +`driver/numeric.rs`. **Algorithm sketch.** diff --git a/installer/src/deps/install.rs b/installer/src/deps/install.rs index 438f3b3a1..34c3b3c48 100644 --- a/installer/src/deps/install.rs +++ b/installer/src/deps/install.rs @@ -44,6 +44,11 @@ pub(super) fn install_missing_tools( let outcome = install_tool(executor, tool, stderr, context)?; update_status_after_install(&mut remaining_status, executor, tool, outcome)?; + write_message( + stderr, + context.quiet, + install_success_message(tool, outcome, context.repo.is_some()), + ); } Ok(()) @@ -111,11 +116,6 @@ pub(super) fn install_tool( match repo.installer.install(dependency, repo.target, repo.dirs) { Ok(_) => { if repository_install_verified(executor, tool)? { - write_message( - stderr, - context.quiet, - format!("Installed {} from repository release.", tool.package), - ); return Ok(InstallOutcome::RepositoryRelease); } refuse_or_announce_fallback(tool, "failed verification", stderr, context)?; @@ -237,7 +237,7 @@ fn install_tool_with_cargo( ) -> Result { // Always try binstall first if available; fall back to cargo install on failure if context.cargo_fallback_mode == InstallMode::Binstall && !cargo_install_plan.skip_binstall { - if try_binstall(executor, cargo_install_plan, stderr, context.quiet)? { + if try_binstall(executor, cargo_install_plan)? { return Ok(InstallOutcome::CargoBinstall); } write_message( @@ -250,14 +250,13 @@ fn install_tool_with_cargo( ); } - run_cargo_install(executor, cargo_install_plan, stderr, context.quiet) + run_cargo_install(executor, cargo_install_plan) } +/// Install through Cargo Binstall and verify the resulting executable. fn try_binstall( executor: &dyn CommandExecutor, cargo_install_plan: CargoInstallPlan<'_>, - stderr: &mut dyn Write, - quiet: bool, ) -> Result { let mut args = vec!["binstall", "-y"]; if let Some(version) = cargo_install_plan.version { @@ -274,36 +273,18 @@ fn try_binstall( return Ok(false); } - write_message( - stderr, - quiet, - format!( - "Installed {} with cargo binstall.", - cargo_install_plan.tool.package - ), - ); Ok(true) } +/// Install through Cargo and verify the resulting executable. fn run_cargo_install( executor: &dyn CommandExecutor, cargo_install_plan: CargoInstallPlan<'_>, - stderr: &mut dyn Write, - quiet: bool, ) -> Result { let mut args = vec!["install"]; - let success_message = if let Some(version) = cargo_install_plan.version { + if let Some(version) = cargo_install_plan.version { args.extend(["--locked", "--version", version]); - format!( - "Installed {} from source with cargo install.", - cargo_install_plan.tool.package - ) - } else { - format!( - "Installed {} with cargo install.", - cargo_install_plan.tool.package - ) - }; + } args.push(cargo_install_plan.tool.package); let output = executor.run("cargo", &args)?; @@ -325,10 +306,31 @@ fn run_cargo_install( }); } - write_message(stderr, quiet, success_message); Ok(InstallOutcome::CargoInstall) } +/// Describe a completed install after its follow-up status checks succeed. +fn install_success_message( + tool: &DependencyTool, + outcome: InstallOutcome, + has_pinned_version: bool, +) -> String { + match outcome { + InstallOutcome::RepositoryRelease => { + format!("Installed {} from repository release.", tool.package) + } + InstallOutcome::CargoBinstall => { + format!("Installed {} with cargo binstall.", tool.package) + } + InstallOutcome::CargoInstall if has_pinned_version => { + format!("Installed {} from source with cargo install.", tool.package) + } + InstallOutcome::CargoInstall => { + format!("Installed {} with cargo install.", tool.package) + } + } +} + /// Verify a repository-release install of `tool`. /// /// The trust boundary for a repository install is established entirely by the diff --git a/installer/src/deps/install_failure_tests.rs b/installer/src/deps/install_failure_tests.rs new file mode 100644 index 000000000..07e2c8bdf --- /dev/null +++ b/installer/src/deps/install_failure_tests.rs @@ -0,0 +1,228 @@ +//! Verify installer command-query errors are propagated through fallback paths. + +use super::*; + +/// Assert that a dependency command's spawn error remains visible to callers. +fn assert_spawn_error(error: InstallerError, expected_message: &str) { + match error { + InstallerError::Io(source) => assert_eq!(source.to_string(), expected_message), + other => panic!("expected command spawn error, got {other}"), + } +} + +/// Assert that a failed dependency path did not report a successful install. +fn assert_no_install_success_message(stderr: &[u8]) { + let stderr = String::from_utf8_lossy(stderr); + assert!( + !stderr.contains("Installed "), + "failure path emitted a success message: {stderr}" + ); +} + +/// Preserve errors while deciding whether Cargo Binstall is available. +#[test] +fn binstall_availability_query_errors_are_propagated() { + let mut repository_installer = MockDependencyBinaryInstaller::new(); + repository_installer.expect_install().never(); + let executor = StubExecutor::new(vec![binstall_version_check_with_result(Err( + InstallerError::Io(std::io::Error::other("binstall lookup failed")), + ))]); + + let mut stderr = Vec::new(); + let error = install_dylint_tools_with_options( + &executor, + &DylintToolStatus { + cargo_dylint: false, + dylint_link: true, + }, + &mut stderr, + install_options(&repository_installer, false), + ) + .expect_err("failed binstall lookup must stop installation"); + + assert_spawn_error(error, "binstall lookup failed"); + assert_no_install_success_message(&stderr); + executor.assert_finished(); +} + +/// Preserve errors from checking an installation reported by Cargo Binstall. +#[test] +fn post_binstall_availability_query_errors_are_propagated() { + let mut repository_installer = MockDependencyBinaryInstaller::new(); + repository_installer + .expect_install() + .returning(|_, _, _| Err(DependencyBinaryInstallError::MissingBinDir)); + let executor = StubExecutor::new(vec![ + binstall_version_check_with_result(Ok(success_output())), + binstall_install("cargo-dylint", Ok(success_output())) + .expect("binstall install call should be configured"), + cargo_dylint_check_with_result(Err(InstallerError::Io(std::io::Error::other( + "post-binstall lookup failed", + )))), + ]); + + let mut stderr = Vec::new(); + let error = install_dylint_tools_with_options( + &executor, + &DylintToolStatus { + cargo_dylint: false, + dylint_link: true, + }, + &mut stderr, + install_options(&repository_installer, false), + ) + .expect_err("failed post-binstall verification must stop installation"); + + assert_spawn_error(error, "post-binstall lookup failed"); + assert_no_install_success_message(&stderr); + executor.assert_finished(); +} + +/// Fall back to Cargo when Binstall succeeds without making the tool available. +#[test] +fn unverified_binstall_install_falls_back_to_cargo_install() { + let mut repository_installer = MockDependencyBinaryInstaller::new(); + repository_installer + .expect_install() + .returning(|_, _, _| Err(DependencyBinaryInstallError::MissingBinDir)); + let executor = StubExecutor::new(vec![ + binstall_version_check_with_result(Ok(success_output())), + binstall_install("cargo-dylint", Ok(success_output())) + .expect("binstall install call should be configured"), + cargo_dylint_check_with_result(Ok(stdout_output("cargo-dylint 5.0.0\n"))), + ExpectedCall { + cmd: "cargo", + args: vec!["install", "--locked", "--version", "6.0.1", "cargo-dylint"], + result: Ok(success_output()), + }, + cargo_dylint_check().expect("cargo-dylint verification should be configured"), + ]); + let mut stderr = Vec::new(); + + install_dylint_tools_with_options( + &executor, + &DylintToolStatus { + cargo_dylint: false, + dylint_link: true, + }, + &mut stderr, + install_options(&repository_installer, false), + ) + .expect("cargo install should recover from an unverified binstall result"); + + let output = String::from_utf8(stderr).expect("stderr should be UTF-8"); + assert!(output.contains("falling back to cargo install")); + assert!(output.contains("Installed cargo-dylint from source with cargo install.")); + executor.assert_finished(); +} + +/// Preserve errors from verifying a successful `cargo install` command. +#[test] +fn post_cargo_install_availability_query_errors_are_propagated() { + let mut repository_installer = MockDependencyBinaryInstaller::new(); + repository_installer + .expect_install() + .returning(|_, _, _| Err(DependencyBinaryInstallError::MissingBinDir)); + let executor = StubExecutor::new(vec![ + binstall_version_check_with_result(Ok(failure_output("missing binstall"))), + ExpectedCall { + cmd: "cargo", + args: vec!["install", "--locked", "--version", "6.0.1", "cargo-dylint"], + result: Ok(success_output()), + }, + cargo_dylint_check_with_result(Err(InstallerError::Io(std::io::Error::other( + "post-cargo-install lookup failed", + )))), + ]); + + let mut stderr = Vec::new(); + let error = install_dylint_tools_with_options( + &executor, + &DylintToolStatus { + cargo_dylint: false, + dylint_link: true, + }, + &mut stderr, + install_options(&repository_installer, false), + ) + .expect_err("failed post-cargo-install verification must stop installation"); + + assert_spawn_error(error, "post-cargo-install lookup failed"); + assert_no_install_success_message(&stderr); + executor.assert_finished(); +} + +/// Preserve errors while verifying a repository-installed `cargo-dylint`. +#[test] +fn repository_release_availability_query_errors_are_propagated() { + let mut repository_installer = MockDependencyBinaryInstaller::new(); + repository_installer + .expect_install() + .returning(|_, _, _| Ok(PathBuf::from("/tmp/bin/cargo-dylint"))); + let executor = StubExecutor::new(vec![ + binstall_version_check_with_result(Ok(failure_output("missing binstall"))), + cargo_dylint_check_with_result(Err(InstallerError::Io(std::io::Error::other( + "repository verification lookup failed", + )))), + ]); + + let mut stderr = Vec::new(); + let error = install_dylint_tools_with_options( + &executor, + &DylintToolStatus { + cargo_dylint: false, + dylint_link: true, + }, + &mut stderr, + install_options(&repository_installer, false), + ) + .expect_err("failed repository verification must stop installation"); + + assert_spawn_error(error, "repository verification lookup failed"); + assert_no_install_success_message(&stderr); + executor.assert_finished(); +} + +/// Preserve errors while refreshing companion `dylint-link` status. +#[test] +fn companion_refresh_query_errors_are_propagated() { + let mut repository_installer = MockDependencyBinaryInstaller::new(); + repository_installer + .expect_install() + .returning(|_, _, _| Err(DependencyBinaryInstallError::MissingBinDir)); + let executor = StubExecutor::new(vec![ + binstall_version_check_with_result(Ok(failure_output("missing binstall"))), + ExpectedCall { + cmd: "cargo", + args: vec!["install", "--locked", "--version", "6.0.1", "cargo-dylint"], + result: Ok(success_output()), + }, + cargo_dylint_check().expect("cargo-dylint verification should be configured"), + ExpectedCall { + cmd: "cargo", + args: vec!["install", "--list"], + result: Err(InstallerError::Io(std::io::Error::other( + "companion refresh lookup failed", + ))), + }, + ]); + + let mut stderr = Vec::new(); + let error = with_fake_binary_on_path("dylint-link", || { + install_dylint_tools_with_options( + &executor, + &DylintToolStatus { + cargo_dylint: false, + dylint_link: false, + }, + &mut stderr, + install_options(&repository_installer, false), + ) + }) + .expect("fake companion should be staged on PATH") + .expect_err("failed companion refresh must stop installation"); + + assert_spawn_error(error, "companion refresh lookup failed"); + assert_no_install_success_message(&stderr); + executor.assert_finished(); +} diff --git a/installer/src/deps/tests.rs b/installer/src/deps/tests.rs index 0f2b1d7ca..1049fd2aa 100644 --- a/installer/src/deps/tests.rs +++ b/installer/src/deps/tests.rs @@ -71,6 +71,9 @@ fn install_dylint_tools_uses_repository_release_first() { executor.assert_finished(); } +#[path = "install_failure_tests.rs"] +mod failure_tests; + #[test] fn install_dylint_tools_falls_back_to_binstall_when_repository_unavailable() { let mut repository_installer = MockDependencyBinaryInstaller::new(); diff --git a/installer/src/main.rs b/installer/src/main.rs index 8eb9feed7..2df4e0840 100644 --- a/installer/src/main.rs +++ b/installer/src/main.rs @@ -36,11 +36,13 @@ use whitaker_installer::wrapper::{generate_wrapper_scripts, path_instructions}; /// Parse options, configure tracing, and run the selected command. fn main() { let cli = Cli::parse(); - tracing_config::configure_tracing(cli.install_args()); let mut stdout = std::io::stdout(); let mut stderr = std::io::stderr(); - let run_result = run(&cli, &mut stdout, &mut stderr); - let exit_code = exit_code_for_run_result(run_result, &mut stderr); + if let Err(error) = tracing_config::configure_tracing(cli.install_args()) { + write_stderr_line(&mut stderr, error); + std::process::exit(1); + } + let exit_code = exit_code_for_run_result(run(&cli, &mut stdout, &mut stderr), &mut stderr); if exit_code != 0 { std::process::exit(exit_code); } diff --git a/installer/src/tracing_config.rs b/installer/src/tracing_config.rs index 5ac5a6e09..ab617c62b 100644 --- a/installer/src/tracing_config.rs +++ b/installer/src/tracing_config.rs @@ -12,9 +12,26 @@ use tracing_subscriber::{ }; use whitaker_installer::cli::InstallArgs; +/// Describe a CLI tracing subscriber registration failure. +#[derive(Debug, thiserror::Error)] +#[error("failed to initialize diagnostic tracing: {source}")] +pub(crate) struct TracingInitializationError { + /// The underlying global subscriber registration failure. + #[source] + source: Box, +} + /// Enable bounded stderr diagnostics according to install verbosity flags. -pub(crate) fn configure_tracing(args: &InstallArgs) { - let _ = tracing_formatter(args).try_init(); +/// +/// # Errors +/// +/// Returns an error if another global subscriber has already been installed. +pub(crate) fn configure_tracing( + args: &InstallArgs, +) -> std::result::Result<(), TracingInitializationError> { + tracing_formatter(args) + .try_init() + .map_err(|source| TracingInitializationError { source }) } /// Build the CLI-only formatter without installing a global subscriber. @@ -138,7 +155,8 @@ mod tests { }; let arguments = std::iter::once("whitaker-installer").chain(flags.iter().copied()); let cli = Cli::try_parse_from(arguments).expect("probe arguments must match the CLI"); - configure_tracing(cli.install_args()); + configure_tracing(cli.install_args()) + .expect("child probe tracing subscriber must initialize"); let _ = crate::install_flow::check_dylint_tools_for_command( &whitaker_installer::deps::SystemCommandExecutor, ); @@ -149,7 +167,8 @@ mod tests { fn tracing_writes_to_stderr() { const PROBE_ENV: &str = "WHITAKER_TRACING_STDERR_PROBE"; if std::env::var_os(PROBE_ENV).is_some() { - configure_tracing(&InstallArgs::default()); + configure_tracing(&InstallArgs::default()) + .expect("child probe tracing subscriber must initialize"); tracing::warn!("installer stderr diagnostic"); return; } @@ -175,6 +194,40 @@ mod tests { assert!(!stderr.contains('\u{1b}'), "stderr must omit ANSI escapes"); } + /// Verify a pre-installed global subscriber becomes a reported CLI error. + #[test] + fn tracing_initialization_failure_is_reported() { + const PROBE_ENV: &str = "WHITAKER_TRACING_INITIALIZATION_FAILURE_PROBE"; + if std::env::var_os(PROBE_ENV).is_some() { + tracing::subscriber::set_global_default(tracing_subscriber::registry()) + .expect("probe subscriber must be installed first"); + let error = configure_tracing(&InstallArgs::default()) + .expect_err("a second global subscriber must be rejected"); + assert!( + error + .to_string() + .contains("failed to initialize diagnostic tracing") + ); + return; + } + + let output = std::process::Command::new( + std::env::current_exe().expect("test executable must be discoverable"), + ) + .args([ + "--exact", + "tracing_config::tests::tracing_initialization_failure_is_reported", + "--nocapture", + ]) + .env(PROBE_ENV, "1") + .output() + .expect("isolated tracing failure probe must run"); + assert!( + output.status.success(), + "tracing failure probe must report the error: {output:?}" + ); + } + /// Verify each verbosity policy through emitted, formatted events. #[rstest] #[case::quiet(true, 0, &["error diagnostic"])] diff --git a/tests/workflow_contracts/nextest_profile_test.py b/tests/workflow_contracts/nextest_profile_test.py index 41dec74f8..c064877a9 100644 --- a/tests/workflow_contracts/nextest_profile_test.py +++ b/tests/workflow_contracts/nextest_profile_test.py @@ -138,14 +138,8 @@ class Allowance(typ.NamedTuple): "test(driver::ui::) | " "test(tests::ui::) | " "test(ui::ui) | " - "test(sha2_0_11_pre_migration_patterns_fail_to_compile) | " "test(example_compiles_without_diagnostics) | " "test(example_harness_collects_call_site_evidence) | " - "test(trybuild_fixtures_compile_without_diagnostics) | " - "test(renamed_common_apis_are_public_and_legacy_names_stay_" - "removed) | " - "test(renamed_sarif_api_is_public_and_legacy_names_stay_" - "removed) | " "test(ui::example_compiles_under_test_harness) | " "test(ui::hand_written_test_companion_does_not_exempt_parent_" "function) | " @@ -204,12 +198,6 @@ class Allowance(typ.NamedTuple): "test(driver::ui::) | " "test(tests::ui::) | " "test(ui::ui) | " - "test(sha2_0_11_pre_migration_patterns_fail_to_compile) | " - "test(trybuild_fixtures_compile_without_diagnostics) | " - "test(renamed_common_apis_are_public_and_legacy_names_stay_" - "removed) | " - "test(renamed_sarif_api_is_public_and_legacy_names_stay_" - "removed) | " "(binary(ui) & test(=ui))" ): Allowance( slow_timeout={ From 0aaedc0f782b713f84d5d47b54fa4ddd26179e38 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 17 Oct 2026 11:33:26 +0200 Subject: [PATCH 31/32] Consolidate installer query failure tests Parameterize the five command-query failure paths so their shared error propagation and output guarantees stay covered without repeating test setup. --- installer/src/deps/install_failure_tests.rs | 281 +++++++++----------- 1 file changed, 122 insertions(+), 159 deletions(-) diff --git a/installer/src/deps/install_failure_tests.rs b/installer/src/deps/install_failure_tests.rs index 07e2c8bdf..b56a7ffbc 100644 --- a/installer/src/deps/install_failure_tests.rs +++ b/installer/src/deps/install_failure_tests.rs @@ -1,6 +1,7 @@ //! Verify installer command-query errors are propagated through fallback paths. use super::*; +use rstest::rstest; /// Assert that a dependency command's spawn error remains visible to callers. fn assert_spawn_error(error: InstallerError, expected_message: &str) { @@ -19,61 +20,134 @@ fn assert_no_install_success_message(stderr: &[u8]) { ); } -/// Preserve errors while deciding whether Cargo Binstall is available. -#[test] -fn binstall_availability_query_errors_are_propagated() { - let mut repository_installer = MockDependencyBinaryInstaller::new(); - repository_installer.expect_install().never(); - let executor = StubExecutor::new(vec![binstall_version_check_with_result(Err( - InstallerError::Io(std::io::Error::other("binstall lookup failed")), - ))]); +/// Identify the installer query whose error must be propagated. +#[derive(Clone, Copy, Debug)] +enum QueryFailure { + BinstallAvailability, + PostBinstallVerification, + PostCargoInstallVerification, + RepositoryVerification, + CompanionRefresh, +} - let mut stderr = Vec::new(); - let error = install_dylint_tools_with_options( - &executor, - &DylintToolStatus { - cargo_dylint: false, - dylint_link: true, - }, - &mut stderr, - install_options(&repository_installer, false), - ) - .expect_err("failed binstall lookup must stop installation"); +impl QueryFailure { + /// Return the error text associated with this query failure. + fn message(self) -> &'static str { + match self { + Self::BinstallAvailability => "binstall lookup failed", + Self::PostBinstallVerification => "post-binstall lookup failed", + Self::PostCargoInstallVerification => "post-cargo-install lookup failed", + Self::RepositoryVerification => "repository verification lookup failed", + Self::CompanionRefresh => "companion refresh lookup failed", + } + } - assert_spawn_error(error, "binstall lookup failed"); - assert_no_install_success_message(&stderr); - executor.assert_finished(); + /// Prepare the repository installer result that reaches this query. + fn configure_repository_installer(self, installer: &mut MockDependencyBinaryInstaller) { + match self { + Self::BinstallAvailability => { + installer.expect_install().never(); + } + Self::RepositoryVerification => { + installer + .expect_install() + .returning(|_, _, _| Ok(PathBuf::from("/tmp/bin/cargo-dylint"))); + } + _ => { + installer + .expect_install() + .returning(|_, _, _| Err(DependencyBinaryInstallError::MissingBinDir)); + } + } + } + + /// Build the command sequence that reaches this failing query. + fn expected_calls(self) -> Vec { + if let Self::BinstallAvailability = self { + return vec![binstall_version_check_with_result(Err(InstallerError::Io( + std::io::Error::other(self.message()), + )))]; + } + + let binstall_available = matches!(self, Self::PostBinstallVerification); + let mut calls = vec![binstall_version_check_with_result(if binstall_available { + Ok(success_output()) + } else { + Ok(failure_output("missing binstall")) + })]; + + if binstall_available { + calls.push( + binstall_install("cargo-dylint", Ok(success_output())) + .expect("binstall install call should be configured"), + ); + } + + if matches!( + self, + Self::PostCargoInstallVerification | Self::CompanionRefresh + ) { + calls.push(ExpectedCall { + cmd: "cargo", + args: vec!["install", "--locked", "--version", "6.0.1", "cargo-dylint"], + result: Ok(success_output()), + }); + } + + match self { + Self::PostBinstallVerification + | Self::PostCargoInstallVerification + | Self::RepositoryVerification => calls.push(cargo_dylint_check_with_result(Err( + InstallerError::Io(std::io::Error::other(self.message())), + ))), + Self::CompanionRefresh => { + calls.push(cargo_dylint_check().expect("cargo-dylint verification is configured")); + calls.push(ExpectedCall { + cmd: "cargo", + args: vec!["install", "--list"], + result: Err(InstallerError::Io(std::io::Error::other(self.message()))), + }); + } + Self::BinstallAvailability => unreachable!("handled before building calls"), + } + + calls + } + + /// Indicate whether this failure occurs while refreshing the companion. + fn refreshes_companion(self) -> bool { + matches!(self, Self::CompanionRefresh) + } } -/// Preserve errors from checking an installation reported by Cargo Binstall. -#[test] -fn post_binstall_availability_query_errors_are_propagated() { +/// Preserve errors from dependency queries across installation paths. +#[rstest] +#[case::binstall_availability(QueryFailure::BinstallAvailability)] +#[case::post_binstall_verification(QueryFailure::PostBinstallVerification)] +#[case::post_cargo_install_verification(QueryFailure::PostCargoInstallVerification)] +#[case::repository_verification(QueryFailure::RepositoryVerification)] +#[case::companion_refresh(QueryFailure::CompanionRefresh)] +fn dependency_query_errors_are_propagated(#[case] failure: QueryFailure) { let mut repository_installer = MockDependencyBinaryInstaller::new(); - repository_installer - .expect_install() - .returning(|_, _, _| Err(DependencyBinaryInstallError::MissingBinDir)); - let executor = StubExecutor::new(vec![ - binstall_version_check_with_result(Ok(success_output())), - binstall_install("cargo-dylint", Ok(success_output())) - .expect("binstall install call should be configured"), - cargo_dylint_check_with_result(Err(InstallerError::Io(std::io::Error::other( - "post-binstall lookup failed", - )))), - ]); - + failure.configure_repository_installer(&mut repository_installer); + let executor = StubExecutor::new(failure.expected_calls()); let mut stderr = Vec::new(); - let error = install_dylint_tools_with_options( - &executor, - &DylintToolStatus { - cargo_dylint: false, - dylint_link: true, - }, - &mut stderr, - install_options(&repository_installer, false), - ) - .expect_err("failed post-binstall verification must stop installation"); - assert_spawn_error(error, "post-binstall lookup failed"); + let error = with_fake_binary_on_path("dylint-link", || { + install_dylint_tools_with_options( + &executor, + &DylintToolStatus { + cargo_dylint: false, + dylint_link: !failure.refreshes_companion(), + }, + &mut stderr, + install_options(&repository_installer, false), + ) + }) + .expect("temporary companion binary should be staged") + .expect_err("query errors must stop installation"); + + assert_spawn_error(error, failure.message()); assert_no_install_success_message(&stderr); executor.assert_finished(); } @@ -115,114 +189,3 @@ fn unverified_binstall_install_falls_back_to_cargo_install() { assert!(output.contains("Installed cargo-dylint from source with cargo install.")); executor.assert_finished(); } - -/// Preserve errors from verifying a successful `cargo install` command. -#[test] -fn post_cargo_install_availability_query_errors_are_propagated() { - let mut repository_installer = MockDependencyBinaryInstaller::new(); - repository_installer - .expect_install() - .returning(|_, _, _| Err(DependencyBinaryInstallError::MissingBinDir)); - let executor = StubExecutor::new(vec![ - binstall_version_check_with_result(Ok(failure_output("missing binstall"))), - ExpectedCall { - cmd: "cargo", - args: vec!["install", "--locked", "--version", "6.0.1", "cargo-dylint"], - result: Ok(success_output()), - }, - cargo_dylint_check_with_result(Err(InstallerError::Io(std::io::Error::other( - "post-cargo-install lookup failed", - )))), - ]); - - let mut stderr = Vec::new(); - let error = install_dylint_tools_with_options( - &executor, - &DylintToolStatus { - cargo_dylint: false, - dylint_link: true, - }, - &mut stderr, - install_options(&repository_installer, false), - ) - .expect_err("failed post-cargo-install verification must stop installation"); - - assert_spawn_error(error, "post-cargo-install lookup failed"); - assert_no_install_success_message(&stderr); - executor.assert_finished(); -} - -/// Preserve errors while verifying a repository-installed `cargo-dylint`. -#[test] -fn repository_release_availability_query_errors_are_propagated() { - let mut repository_installer = MockDependencyBinaryInstaller::new(); - repository_installer - .expect_install() - .returning(|_, _, _| Ok(PathBuf::from("/tmp/bin/cargo-dylint"))); - let executor = StubExecutor::new(vec![ - binstall_version_check_with_result(Ok(failure_output("missing binstall"))), - cargo_dylint_check_with_result(Err(InstallerError::Io(std::io::Error::other( - "repository verification lookup failed", - )))), - ]); - - let mut stderr = Vec::new(); - let error = install_dylint_tools_with_options( - &executor, - &DylintToolStatus { - cargo_dylint: false, - dylint_link: true, - }, - &mut stderr, - install_options(&repository_installer, false), - ) - .expect_err("failed repository verification must stop installation"); - - assert_spawn_error(error, "repository verification lookup failed"); - assert_no_install_success_message(&stderr); - executor.assert_finished(); -} - -/// Preserve errors while refreshing companion `dylint-link` status. -#[test] -fn companion_refresh_query_errors_are_propagated() { - let mut repository_installer = MockDependencyBinaryInstaller::new(); - repository_installer - .expect_install() - .returning(|_, _, _| Err(DependencyBinaryInstallError::MissingBinDir)); - let executor = StubExecutor::new(vec![ - binstall_version_check_with_result(Ok(failure_output("missing binstall"))), - ExpectedCall { - cmd: "cargo", - args: vec!["install", "--locked", "--version", "6.0.1", "cargo-dylint"], - result: Ok(success_output()), - }, - cargo_dylint_check().expect("cargo-dylint verification should be configured"), - ExpectedCall { - cmd: "cargo", - args: vec!["install", "--list"], - result: Err(InstallerError::Io(std::io::Error::other( - "companion refresh lookup failed", - ))), - }, - ]); - - let mut stderr = Vec::new(); - let error = with_fake_binary_on_path("dylint-link", || { - install_dylint_tools_with_options( - &executor, - &DylintToolStatus { - cargo_dylint: false, - dylint_link: false, - }, - &mut stderr, - install_options(&repository_installer, false), - ) - }) - .expect("fake companion should be staged on PATH") - .expect_err("failed companion refresh must stop installation"); - - assert_spawn_error(error, "companion refresh lookup failed"); - assert_no_install_success_message(&stderr); - executor.assert_finished(); -} From ff994dfbecf3c729f41dc183dcef714edb0337d6 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 17 Oct 2026 12:14:42 +0200 Subject: [PATCH 32/32] Document touched Rust functions (#398) Add behavior-focused Rustdoc to functions changed by PR #363 and clarify installer errors, HIR context, and PATH-scan behavior to satisfy its coverage and documentation acceptance criteria. --- .../src/driver/segment_builder.rs | 6 ++++ .../src/context/mod.rs | 22 +++---------- .../src/context/mod.rs | 5 ++- .../whitaker_clones_core/src/index/tests.rs | 2 +- installer/src/deps/install.rs | 32 +++++++++++++++++++ installer/src/deps/mod.rs | 13 +++++++- installer/src/deps/path_tests.rs | 25 +++++++++++++++ installer/src/error.rs | 1 + installer/src/error_tests.rs | 1 + installer/src/tests/mod.rs | 15 +++++++++ 10 files changed, 101 insertions(+), 21 deletions(-) diff --git a/crates/bumpy_road_function/src/driver/segment_builder.rs b/crates/bumpy_road_function/src/driver/segment_builder.rs index b021719d4..324e17913 100644 --- a/crates/bumpy_road_function/src/driver/segment_builder.rs +++ b/crates/bumpy_road_function/src/driver/segment_builder.rs @@ -162,10 +162,12 @@ impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { self.visit_expr(expr); } + /// Add the configured nesting-depth weight for `span`. fn push_depth_segment(&mut self, span: Span) { self.push_segment(span, SegmentWeight::Fixed(self.settings.weights.depth)); } + /// Add the configured control-flow weight for `span`. fn push_flow_segment(&mut self, span: Span) { self.push_segment(span, SegmentWeight::Fixed(self.settings.weights.flow)); } @@ -188,6 +190,10 @@ impl<'a, 'tcx> SegmentBuilder<'a, 'tcx> { ); } + /// Add a weighted segment when `span` maps to lines within this function. + /// + /// Ignore macro-expanded or unmappable spans; report an internal delayed + /// bug for invalid segments or lines outside the function range. fn push_segment(&mut self, span: Span, weight: SegmentWeight) { // Skip spans from macro expansions; their line numbers can point to // locations outside the function (e.g., the macro definition site). diff --git a/crates/no_expect_outside_tests/src/context/mod.rs b/crates/no_expect_outside_tests/src/context/mod.rs index fd29d750c..4b9af71e9 100644 --- a/crates/no_expect_outside_tests/src/context/mod.rs +++ b/crates/no_expect_outside_tests/src/context/mod.rs @@ -87,25 +87,11 @@ fn has_test_ancestry( || (is_function_item && has_test_like_hir_attributes(attrs, additional_test_attributes)) } -/// Summarizes collected ancestor context into the lint's final test decision. +/// Derive whether a call site is test-only and find its innermost function. /// -/// Combines the pre-computed ancestor entries with the carried -/// `has_test_context_ancestry` flag so the lint can determine whether the -/// current call site should be treated as test-only code. -/// -/// # Parameters -/// -/// - `entries`: Simplified ancestor contexts produced by `collect_context`. -/// - `has_test_context_ancestry`: Whether any ancestor already established -/// test-only ancestry via propagation, `cfg(test)`, or a recognized -/// test-marker attribute. -/// - `additional_test_attributes`: Extra user-configured attribute paths that -/// should be considered test markers during the final summary check. -/// -/// # Returns -/// -/// Returns a `ContextSummary` describing the derived test-context status and -/// the innermost enclosing function name, if one was found. +/// The entries are simplified HIR ancestors; the ancestry flag carries test +/// context such as `cfg(test)`, and configured attributes add test markers. +/// The summary includes test-like status and the innermost enclosing function. /// /// # Examples /// diff --git a/crates/no_unwrap_or_else_panic/src/context/mod.rs b/crates/no_unwrap_or_else_panic/src/context/mod.rs index 58426ee47..16a0c71fe 100644 --- a/crates/no_unwrap_or_else_panic/src/context/mod.rs +++ b/crates/no_unwrap_or_else_panic/src/context/mod.rs @@ -29,7 +29,10 @@ use rustc_lint::LateContext; #[cfg(feature = "dylint-driver")] use rustc_span::sym; -/// Summarize the context for a given HIR node. +/// Derive test status and `main` membership from a HIR node's ancestry. +/// +/// Ancestor `cfg(test)` attributes contribute to test status alongside the +/// test-like scopes and attributes represented by context entries. #[cfg(feature = "dylint-driver")] pub(crate) fn summarize_context<'tcx>( cx: &LateContext<'tcx>, diff --git a/crates/whitaker_clones_core/src/index/tests.rs b/crates/whitaker_clones_core/src/index/tests.rs index b5ad32952..5f09180c2 100644 --- a/crates/whitaker_clones_core/src/index/tests.rs +++ b/crates/whitaker_clones_core/src/index/tests.rs @@ -34,7 +34,7 @@ fn multi_band_config() -> LshConfig { LshConfig::new(32, 4).expect("multi-band config should validate") } -/// Create the shared signature fixture used by index tests. +/// Create a deterministic signature shared by fixtures using `[1, 2, 3, 4]`. #[allow_fixture_expansion_lints] #[fixture] fn shared_signature() -> MinHashSignature { diff --git a/installer/src/deps/install.rs b/installer/src/deps/install.rs index 34c3b3c48..d018e32bf 100644 --- a/installer/src/deps/install.rs +++ b/installer/src/deps/install.rs @@ -29,6 +29,12 @@ pub(super) struct InstallContext<'a> { pub(super) no_source_fallback: bool, } +/// Install each missing dependency and refresh availability after installation. +/// +/// # Errors +/// +/// Returns an error if installation, verification, or a follow-up availability +/// query fails. pub(super) fn install_missing_tools( executor: &dyn CommandExecutor, status: &DylintToolStatus, @@ -69,6 +75,11 @@ pub(super) fn repository_install_context<'a>( } } +/// Select Cargo Binstall when available, otherwise use `cargo install`. +/// +/// # Errors +/// +/// Returns an error if the Binstall availability query fails. pub(super) fn cargo_fallback_mode(executor: &dyn CommandExecutor) -> Result { Ok(if is_binstall_available(executor)? { InstallMode::Binstall @@ -87,6 +98,12 @@ pub(super) fn should_install_tool(status: &DylintToolStatus, tool: &DependencyTo } } +/// Install one dependency, preferring its repository release when configured. +/// +/// # Errors +/// +/// Returns an error if dependency metadata, installation, source policy, or +/// post-install verification fails. pub(super) fn install_tool( executor: &dyn CommandExecutor, tool: &DependencyTool, @@ -229,6 +246,11 @@ impl<'a> CargoInstallPlan<'a> { } } +/// Install a dependency through Cargo, trying Binstall before source install. +/// +/// # Errors +/// +/// Returns an error if a Cargo command cannot run or source installation fails. fn install_tool_with_cargo( executor: &dyn CommandExecutor, cargo_install_plan: CargoInstallPlan<'_>, @@ -377,6 +399,11 @@ pub(super) fn write_message(stderr: &mut dyn Write, quiet: bool, message: String let _ = writeln!(stderr, "{message}"); } +/// Run a command and report whether it exits successfully. +/// +/// # Errors +/// +/// Returns an error if the command cannot be started or completed. pub(super) fn command_succeeds( executor: &dyn CommandExecutor, cmd: &str, @@ -389,6 +416,11 @@ fn should_refresh_companions(outcome: InstallOutcome, status: &DylintToolStatus) outcome != InstallOutcome::RepositoryRelease && !status.dylint_link } +/// Record an installed tool and recheck companion binaries when needed. +/// +/// # Errors +/// +/// Returns an error if checking for the companion `dylint-link` binary fails. fn update_status_after_install( status: &mut DylintToolStatus, executor: &dyn CommandExecutor, diff --git a/installer/src/deps/mod.rs b/installer/src/deps/mod.rs index 336271e4f..cd018aa3b 100644 --- a/installer/src/deps/mod.rs +++ b/installer/src/deps/mod.rs @@ -122,6 +122,7 @@ pub(crate) trait BinaryLocator { pub(crate) struct SystemBinaryLocator; impl BinaryLocator for SystemBinaryLocator { + /// Search the process PATH and classify failures from skipped entries. fn find_binary(&self, binary_name: &str) -> PathScanResult { find_binary_on_path(binary_name) } @@ -266,6 +267,10 @@ pub fn install_dylint_tools( } /// Install missing tools while writing progress output to `stderr`. +/// +/// # Errors +/// +/// Returns query, installation, or source-policy errors from the install flow. pub fn install_dylint_tools_with_output( executor: &dyn CommandExecutor, status: &DylintToolStatus, @@ -295,6 +300,10 @@ pub fn install_dylint_tools_with_output( } /// Install missing tools with injected repository-install hooks. +/// +/// # Errors +/// +/// Returns query, installation, or source-policy errors from the install flow. #[cfg(any(test, feature = "test-support"))] #[doc(hidden)] pub fn install_dylint_tools_with_options( @@ -361,7 +370,9 @@ fn is_tool_installed_with_locator( /// /// Presence is established by finding an executable on PATH without running /// the linker wrapper. An expected version is compared with Cargo's install -/// list; skipped-entry classifications are traced here, without logging paths. +/// list. If PATH scanning finds no executable after entry failures, or the +/// Cargo query fails, return an error; skipped-entry classifications are +/// collected for the caller. fn is_dylint_link_installed( executor: &dyn CommandExecutor, expected_version: &str, diff --git a/installer/src/deps/path_tests.rs b/installer/src/deps/path_tests.rs index 8f2d74df0..b3a5b03d1 100644 --- a/installer/src/deps/path_tests.rs +++ b/installer/src/deps/path_tests.rs @@ -22,6 +22,11 @@ use crate::{ }, }; +/// Verify both installed tools are reported as available. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[test] fn check_dylint_tools_reports_installed_tools() -> std::io::Result<()> { with_fake_binary_on_path("dylint-link", || { @@ -46,6 +51,11 @@ fn check_dylint_tools_reports_installed_tools() -> std::io::Result<()> { Ok(()) } +/// Reject Cargo output that does not report the pinned `cargo-dylint` version. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[rstest::rstest] #[case::stale_version("cargo-dylint 5.0.0\n")] fn check_dylint_tools_rejects_unusable_cargo_dylint_output( @@ -75,6 +85,11 @@ fn check_dylint_tools_rejects_unusable_cargo_dylint_output( Ok(()) } +/// Reject successful Cargo output that contains no parseable version. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[test] fn check_dylint_tools_rejects_unparsable_cargo_dylint_output() -> std::io::Result<()> { with_fake_path( @@ -98,6 +113,11 @@ fn check_dylint_tools_rejects_unparsable_cargo_dylint_output() -> std::io::Resul Ok(()) } +/// Reject `dylint-link` when Cargo does not record the pinned installed version. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[rstest::rstest] #[case::stale_version(dylint_link_install_list_check_with_version("5.0.0"))] #[case::missing_from_list(ExpectedCall { @@ -421,6 +441,11 @@ fn is_binary_on_path_handles_windows_executable_suffixes( Ok(()) } +/// Detect `dylint-link` through a suffix supplied by the Windows `PATHEXT`. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[cfg(windows)] #[test] fn check_dylint_tools_detects_dylint_link_via_pathext_suffix() -> std::io::Result<()> { diff --git a/installer/src/error.rs b/installer/src/error.rs index c91a706b2..25c684ab8 100644 --- a/installer/src/error.rs +++ b/installer/src/error.rs @@ -285,6 +285,7 @@ impl InstallerError { } impl Clone for InstallerError { + /// Clone every error variant while preserving its displayable details. fn clone(&self) -> Self { if let Some(value) = self .clone_toolchain_variant() diff --git a/installer/src/error_tests.rs b/installer/src/error_tests.rs index 0411d9821..e7066da24 100644 --- a/installer/src/error_tests.rs +++ b/installer/src/error_tests.rs @@ -132,6 +132,7 @@ fn source_fallback_forbidden_carries_the_artefact_and_the_reason() { assert!(message.contains("not found"), "got: {message}"); } +/// Verify policy and I/O error variants retain their messages when cloned. #[test] fn policy_and_io_errors_survive_a_clone() { // `InstallerError::clone` matches every variant by hand, so a new variant diff --git a/installer/src/tests/mod.rs b/installer/src/tests/mod.rs index 6dcb1dbd3..c9cf3adde 100644 --- a/installer/src/tests/mod.rs +++ b/installer/src/tests/mod.rs @@ -124,6 +124,11 @@ fn resolve_requested_crates_rejects_unknown_lints() { )); } +/// Skip installation when both required Dylint tools are already installed. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[rstest] fn ensure_dylint_tools_skips_install_when_installed( test_base_dirs: TestBaseDirs, @@ -153,6 +158,11 @@ fn ensure_dylint_tools_skips_install_when_installed( Ok(()) } +/// Install missing Dylint tools and check the configured progress output. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[rstest] #[case::logs_when_not_quiet( false, @@ -217,6 +227,11 @@ fn ensure_dylint_tools_installs_missing_tools( Ok(()) } +/// Propagate a failure from the dependency installation command. +/// +/// # Errors +/// +/// Returns an I/O error if the temporary PATH fixture cannot be created. #[rstest] fn ensure_dylint_tools_propagates_install_failures( test_base_dirs: TestBaseDirs,