From 4d79840847f313f198a6a623b8f84ff8891cca37 Mon Sep 17 00:00:00 2001 From: ya7010 Date: Thu, 13 Aug 2026 15:21:13 +0900 Subject: [PATCH] fix(validator): correct numeric bound comparisons --- crates/tombi-linter/tests/integration.rs | 2 + .../integration/numeric_bounds_test_schema.rs | 123 ++++++++++++++++++ crates/tombi-validator/src/diagnostic.rs | 24 ++-- crates/tombi-validator/src/validate.rs | 20 +++ crates/tombi-validator/src/validate/float.rs | 13 +- .../tombi-validator/src/validate/integer.rs | 25 ++-- schemas/numeric-bounds-test.schema.json | 46 +++++++ 7 files changed, 223 insertions(+), 30 deletions(-) create mode 100644 crates/tombi-linter/tests/integration/numeric_bounds_test_schema.rs create mode 100644 schemas/numeric-bounds-test.schema.json diff --git a/crates/tombi-linter/tests/integration.rs b/crates/tombi-linter/tests/integration.rs index 4ce744fd3..7e2f586aa 100644 --- a/crates/tombi-linter/tests/integration.rs +++ b/crates/tombi-linter/tests/integration.rs @@ -30,6 +30,8 @@ mod min_max_contains_test_schema; mod non_schema; #[path = "integration/not_schema.rs"] mod not_schema; +#[path = "integration/numeric_bounds_test_schema.rs"] +mod numeric_bounds_test_schema; #[path = "integration/other_schema.rs"] mod other_schema; #[path = "integration/prefix_items_test_schema.rs"] diff --git a/crates/tombi-linter/tests/integration/numeric_bounds_test_schema.rs b/crates/tombi-linter/tests/integration/numeric_bounds_test_schema.rs new file mode 100644 index 000000000..5bfd384c8 --- /dev/null +++ b/crates/tombi-linter/tests/integration/numeric_bounds_test_schema.rs @@ -0,0 +1,123 @@ +use std::path::PathBuf; + +use tombi_linter::test_lint; +use tombi_test_lib::project_root_path; + +fn schema_path() -> PathBuf { + project_root_path() + .join("schemas") + .join("numeric-bounds-test.schema.json") +} + +test_lint! { + #[test] + fn test_numeric_bounds_accept_satisfied_relations( + r#" + integer_maximum = 10 + integer_minimum = 10 + integer_exclusive_maximum_normal = 9 + integer_exclusive_minimum_normal = 11 + float_maximum = 10.0 + float_minimum = 10.0 + float_exclusive_maximum = 9.5 + float_exclusive_minimum = 10.5 + "#, + SchemaPath(schema_path()), + ) -> Ok(_) +} + +test_lint! { + #[test] + fn test_integer_maximum_is_inclusive( + "integer_maximum = 11", + SchemaPath(schema_path()), + ) -> Err(["The value must be ≤ 10, but found 11"]) +} + +test_lint! { + #[test] + fn test_integer_minimum_is_inclusive( + "integer_minimum = 9", + SchemaPath(schema_path()), + ) -> Err(["The value must be ≥ 10, but found 9"]) +} + +test_lint! { + #[test] + fn test_integer_exclusive_maximum_uses_schema_boundary( + "integer_exclusive_maximum = -9223372036854775808", + SchemaPath(schema_path()), + ) -> Err(["The value must be < -9223372036854775808, but found -9223372036854775808"]) +} + +test_lint! { + #[test] + fn test_integer_exclusive_minimum_uses_schema_boundary( + "integer_exclusive_minimum = 9223372036854775807", + SchemaPath(schema_path()), + ) -> Err(["The value must be > 9223372036854775807, but found 9223372036854775807"]) +} + +test_lint! { + #[test] + fn test_float_maximum_is_inclusive( + "float_maximum = 10.5", + SchemaPath(schema_path()), + ) -> Err(["The value must be ≤ 10, but found 10.5"]) +} + +test_lint! { + #[test] + fn test_float_minimum_is_inclusive( + "float_minimum = 9.5", + SchemaPath(schema_path()), + ) -> Err(["The value must be ≥ 10, but found 9.5"]) +} + +test_lint! { + #[test] + fn test_float_exclusive_maximum_is_exclusive( + "float_exclusive_maximum = 10.0", + SchemaPath(schema_path()), + ) -> Err(["The value must be < 10, but found 10"]) +} + +test_lint! { + #[test] + fn test_float_exclusive_minimum_is_exclusive( + "float_exclusive_minimum = 10.0", + SchemaPath(schema_path()), + ) -> Err(["The value must be > 10, but found 10"]) +} + +test_lint! { + #[test] + fn test_float_maximum_rejects_nan( + "float_maximum = nan", + SchemaPath(schema_path()), + ) -> Err(["The value must be ≤ 10, but found NaN"]) +} + +test_lint! { + #[test] + fn test_float_minimum_rejects_nan( + "float_minimum = nan", + SchemaPath(schema_path()), + ) -> Err(["The value must be ≥ 10, but found NaN"]) +} + +test_lint! { + #[test] + fn test_float_exclusive_maximum_rejects_nan( + "float_exclusive_maximum = nan", + SchemaPath(schema_path()), + ) -> Err(["The value must be < 10, but found NaN"]) +} + +test_lint! { + #[test] + fn test_float_exclusive_minimum_rejects_nan( + "float_exclusive_minimum = nan", + SchemaPath(schema_path()), + ) -> Err(["The value must be > 10, but found NaN"]) +} diff --git a/crates/tombi-validator/src/diagnostic.rs b/crates/tombi-validator/src/diagnostic.rs index 0733b86e5..fe482568f 100644 --- a/crates/tombi-validator/src/diagnostic.rs +++ b/crates/tombi-validator/src/diagnostic.rs @@ -75,32 +75,32 @@ pub enum DiagnosticKind { actual: String, }, - #[error("The value must be < {maximum}, but found {actual}")] + #[error("The value must be ≤ {maximum}, but found {actual}")] IntegerMaximum { maximum: i64, actual: i64 }, - #[error("The value must be > {minimum}, but found {actual}")] + #[error("The value must be ≥ {minimum}, but found {actual}")] IntegerMinimum { minimum: i64, actual: i64 }, - #[error("The value must be ≤ {maximum}, but found {actual}")] - IntegerExclusiveMaximum { maximum: i64, actual: i64 }, + #[error("The value must be < {exclusive_maximum}, but found {actual}")] + IntegerExclusiveMaximum { exclusive_maximum: i64, actual: i64 }, - #[error("The value must be ≥ {minimum}, but found {actual}")] - IntegerExclusiveMinimum { minimum: i64, actual: i64 }, + #[error("The value must be > {exclusive_minimum}, but found {actual}")] + IntegerExclusiveMinimum { exclusive_minimum: i64, actual: i64 }, #[error("The value {actual} is not a multiple of {multiple_of}")] IntegerMultipleOf { multiple_of: i64, actual: i64 }, - #[error("The value must be < {maximum}, but found {actual}")] + #[error("The value must be ≤ {maximum}, but found {actual}")] FloatMaximum { maximum: f64, actual: f64 }, - #[error("The value must be > {minimum}, but found {actual}")] + #[error("The value must be ≥ {minimum}, but found {actual}")] FloatMinimum { minimum: f64, actual: f64 }, - #[error("The value must be ≤ {maximum}, but found {actual}")] - FloatExclusiveMaximum { maximum: f64, actual: f64 }, + #[error("The value must be < {exclusive_maximum}, but found {actual}")] + FloatExclusiveMaximum { exclusive_maximum: f64, actual: f64 }, - #[error("The value must be ≥ {minimum}, but found {actual}")] - FloatExclusiveMinimum { minimum: f64, actual: f64 }, + #[error("The value must be > {exclusive_minimum}, but found {actual}")] + FloatExclusiveMinimum { exclusive_minimum: f64, actual: f64 }, #[error("The value {actual} is not a multiple of {multiple_of}")] FloatMultipleOf { multiple_of: f64, actual: f64 }, diff --git a/crates/tombi-validator/src/validate.rs b/crates/tombi-validator/src/validate.rs index fccb4f9d7..170b4c8b0 100644 --- a/crates/tombi-validator/src/validate.rs +++ b/crates/tombi-validator/src/validate.rs @@ -90,6 +90,26 @@ pub trait Validate { ) -> BoxFuture<'b, Result>; } +#[inline] +fn check_maximum(value: &T, maximum: &T) -> bool { + value <= maximum +} + +#[inline] +fn check_minimum(value: &T, minimum: &T) -> bool { + value >= minimum +} + +#[inline] +fn check_exclusive_maximum(value: &T, exclusive_maximum: &T) -> bool { + value < exclusive_maximum +} + +#[inline] +fn check_exclusive_minimum(value: &T, exclusive_minimum: &T) -> bool { + value > exclusive_minimum +} + pub fn project_current_schema_for_value( value: &impl tombi_document_tree::ValueImpl, current_schema: Option<&tombi_schema_store::CurrentSchema<'_>>, diff --git a/crates/tombi-validator/src/validate/float.rs b/crates/tombi-validator/src/validate/float.rs index 43ec411c9..38e3a23c1 100644 --- a/crates/tombi-validator/src/validate/float.rs +++ b/crates/tombi-validator/src/validate/float.rs @@ -7,6 +7,7 @@ use tombi_severity_level::SeverityLevelDefaultError; use crate::{ comment_directive::get_tombi_key_table_value_rules_and_diagnostics, validate::{ + check_exclusive_maximum, check_exclusive_minimum, check_maximum, check_minimum, handle_anything_schema, handle_deprecated_value, handle_nothing_schema, handle_unused_noqa, is_multiple_of_with_tolerance, validate_adjacent_applicators, }, @@ -202,7 +203,7 @@ async fn validate_float( } if let Some(maximum) = &float_schema.maximum - && value > *maximum + && !check_maximum(&value, maximum) { let level = lint_rules .map(|rules| &rules.value) @@ -236,7 +237,7 @@ async fn validate_float( } if let Some(minimum) = &float_schema.minimum - && value < *minimum + && !check_minimum(&value, minimum) { let level = lint_rules .map(|rules| &rules.value) @@ -270,7 +271,7 @@ async fn validate_float( } if let Some(exclusive_maximum) = &float_schema.exclusive_maximum - && value >= *exclusive_maximum + && !check_exclusive_maximum(&value, exclusive_maximum) { let level = lint_rules .map(|rules| &rules.value) @@ -284,7 +285,7 @@ async fn validate_float( crate::Diagnostic { kind: Box::new(crate::DiagnosticKind::FloatExclusiveMaximum { - maximum: *exclusive_maximum, + exclusive_maximum: *exclusive_maximum, actual: value, }), range, @@ -304,7 +305,7 @@ async fn validate_float( } if let Some(exclusive_minimum) = &float_schema.exclusive_minimum - && value <= *exclusive_minimum + && !check_exclusive_minimum(&value, exclusive_minimum) { let level = lint_rules .map(|rules| &rules.value) @@ -318,7 +319,7 @@ async fn validate_float( crate::Diagnostic { kind: Box::new(crate::DiagnosticKind::FloatExclusiveMinimum { - minimum: *exclusive_minimum, + exclusive_minimum: *exclusive_minimum, actual: value, }), range, diff --git a/crates/tombi-validator/src/validate/integer.rs b/crates/tombi-validator/src/validate/integer.rs index a1d8bb6e2..654b108a5 100644 --- a/crates/tombi-validator/src/validate/integer.rs +++ b/crates/tombi-validator/src/validate/integer.rs @@ -7,6 +7,7 @@ use tombi_severity_level::SeverityLevelDefaultError; use crate::{ comment_directive::get_tombi_key_table_value_rules_and_diagnostics, validate::{ + check_exclusive_maximum, check_exclusive_minimum, check_maximum, check_minimum, handle_anything_schema, handle_deprecated_value, handle_nothing_schema, handle_unused_noqa, is_multiple_of_with_tolerance, validate_adjacent_applicators, }, @@ -215,7 +216,7 @@ async fn validate_integer_schema( } if let Some(maximum) = &integer_schema.maximum - && value > *maximum + && !check_maximum(&value, maximum) { let level = lint_rules .map(|rules| &rules.value) @@ -249,7 +250,7 @@ async fn validate_integer_schema( } if let Some(minimum) = &integer_schema.minimum - && value < *minimum + && !check_minimum(&value, minimum) { let level = lint_rules .map(|rules| &rules.value) @@ -283,7 +284,7 @@ async fn validate_integer_schema( } if let Some(exclusive_maximum) = &integer_schema.exclusive_maximum - && value >= *exclusive_maximum + && !check_exclusive_maximum(&value, exclusive_maximum) { let level = lint_rules .map(|rules| &rules.value) @@ -297,7 +298,7 @@ async fn validate_integer_schema( crate::Diagnostic { kind: Box::new(crate::DiagnosticKind::IntegerExclusiveMaximum { - maximum: *exclusive_maximum - 1, + exclusive_maximum: *exclusive_maximum, actual: value, }), range, @@ -317,7 +318,7 @@ async fn validate_integer_schema( } if let Some(exclusive_minimum) = &integer_schema.exclusive_minimum - && value <= *exclusive_minimum + && !check_exclusive_minimum(&value, exclusive_minimum) { let level = lint_rules .map(|rules| &rules.value) @@ -331,7 +332,7 @@ async fn validate_integer_schema( crate::Diagnostic { kind: Box::new(crate::DiagnosticKind::IntegerExclusiveMinimum { - minimum: *exclusive_minimum + 1, + exclusive_minimum: *exclusive_minimum, actual: value, }), range, @@ -476,7 +477,7 @@ async fn validate_float_schema_for_integer( } if let Some(maximum) = &float_schema.maximum - && value > *maximum + && !check_maximum(&value, maximum) { let level = lint_rules .map(|rules| &rules.value) @@ -499,7 +500,7 @@ async fn validate_float_schema_for_integer( } if let Some(minimum) = &float_schema.minimum - && value < *minimum + && !check_minimum(&value, minimum) { let level = lint_rules .map(|rules| &rules.value) @@ -522,7 +523,7 @@ async fn validate_float_schema_for_integer( } if let Some(exclusive_maximum) = &float_schema.exclusive_maximum - && value >= *exclusive_maximum + && !check_exclusive_maximum(&value, exclusive_maximum) { let level = lint_rules .map(|rules| &rules.value) @@ -536,7 +537,7 @@ async fn validate_float_schema_for_integer( crate::Diagnostic { kind: Box::new(crate::DiagnosticKind::IntegerExclusiveMaximum { - maximum: (*exclusive_maximum as i64) - 1, + exclusive_maximum: *exclusive_maximum as i64, actual: value as i64, }), range, @@ -545,7 +546,7 @@ async fn validate_float_schema_for_integer( } if let Some(exclusive_minimum) = &float_schema.exclusive_minimum - && value <= *exclusive_minimum + && !check_exclusive_minimum(&value, exclusive_minimum) { let level = lint_rules .map(|rules| &rules.value) @@ -559,7 +560,7 @@ async fn validate_float_schema_for_integer( crate::Diagnostic { kind: Box::new(crate::DiagnosticKind::IntegerExclusiveMinimum { - minimum: (*exclusive_minimum as i64) + 1, + exclusive_minimum: *exclusive_minimum as i64, actual: value as i64, }), range, diff --git a/schemas/numeric-bounds-test.schema.json b/schemas/numeric-bounds-test.schema.json new file mode 100644 index 000000000..64b2e8b10 --- /dev/null +++ b/schemas/numeric-bounds-test.schema.json @@ -0,0 +1,46 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "integer_maximum": { + "type": "integer", + "maximum": 10 + }, + "integer_minimum": { + "type": "integer", + "minimum": 10 + }, + "integer_exclusive_maximum": { + "type": "integer", + "exclusiveMaximum": -9223372036854775808 + }, + "integer_exclusive_minimum": { + "type": "integer", + "exclusiveMinimum": 9223372036854775807 + }, + "integer_exclusive_maximum_normal": { + "type": "integer", + "exclusiveMaximum": 10 + }, + "integer_exclusive_minimum_normal": { + "type": "integer", + "exclusiveMinimum": 10 + }, + "float_maximum": { + "type": "number", + "maximum": 10 + }, + "float_minimum": { + "type": "number", + "minimum": 10 + }, + "float_exclusive_maximum": { + "type": "number", + "exclusiveMaximum": 10 + }, + "float_exclusive_minimum": { + "type": "number", + "exclusiveMinimum": 10 + } + } +}