From ea1b361a4552259e916a434993acf61ca36cb273 Mon Sep 17 00:00:00 2001 From: Vaibhav mittal Date: Fri, 27 Mar 2026 17:09:23 +0000 Subject: [PATCH 1/3] feat(linter): add incoherent_min_max_contains rule Signed-off-by: Vaibhav mittal --- src/extension/alterschema/CMakeLists.txt | 1 + src/extension/alterschema/alterschema.cc | 2 + .../linter/incoherent_min_max_contains.h | 32 +++ .../alterschema_lint_2019_09_test.cc | 183 ++++++++++++++++++ .../alterschema_lint_2020_12_test.cc | 183 ++++++++++++++++++ 5 files changed, 401 insertions(+) create mode 100644 src/extension/alterschema/linter/incoherent_min_max_contains.h diff --git a/src/extension/alterschema/CMakeLists.txt b/src/extension/alterschema/CMakeLists.txt index 5fe9f4e93..d17efb350 100644 --- a/src/extension/alterschema/CMakeLists.txt +++ b/src/extension/alterschema/CMakeLists.txt @@ -85,6 +85,7 @@ sourcemeta_library(NAMESPACE sourcemeta PROJECT core NAME alterschema linter/enum_to_const.h linter/equal_numeric_bounds_to_const.h linter/forbid_empty_enum.h + linter/incoherent_min_max_contains.h linter/invalid_external_ref.h linter/items_array_default.h linter/items_schema_default.h diff --git a/src/extension/alterschema/alterschema.cc b/src/extension/alterschema/alterschema.cc index a6e9744c9..d20eff011 100644 --- a/src/extension/alterschema/alterschema.cc +++ b/src/extension/alterschema/alterschema.cc @@ -114,6 +114,7 @@ inline auto APPLIES_TO_POINTERS(std::vector &&keywords) #include "linter/enum_to_const.h" #include "linter/equal_numeric_bounds_to_const.h" #include "linter/forbid_empty_enum.h" +#include "linter/incoherent_min_max_contains.h" #include "linter/invalid_external_ref.h" #include "linter/items_array_default.h" #include "linter/items_schema_default.h" @@ -231,6 +232,7 @@ auto add(SchemaTransformer &bundle, const AlterSchemaMode mode) -> void { bundle.add(); bundle.add(); bundle.add(); + bundle.add(); bundle.add(); bundle.add(); bundle.add(); diff --git a/src/extension/alterschema/linter/incoherent_min_max_contains.h b/src/extension/alterschema/linter/incoherent_min_max_contains.h new file mode 100644 index 000000000..d5d1f5947 --- /dev/null +++ b/src/extension/alterschema/linter/incoherent_min_max_contains.h @@ -0,0 +1,32 @@ +class IncoherentMinMaxContains final : public SchemaTransformRule { +public: + using mutates = std::false_type; + using reframe_after_transform = std::false_type; + IncoherentMinMaxContains() + : SchemaTransformRule{ + "incoherent_min_max_contains", + "`minContains` greater than `maxContains` makes the schema " + "unsatisfiable"} {}; + + [[nodiscard]] auto + condition(const sourcemeta::core::JSON &schema, + const sourcemeta::core::JSON &, + const sourcemeta::core::Vocabularies &vocabularies, + const sourcemeta::core::SchemaFrame &, + const sourcemeta::core::SchemaFrame::Location &, + const sourcemeta::core::SchemaWalker &, + const sourcemeta::core::SchemaResolver &) const + -> sourcemeta::core::SchemaTransformRule::Result override { + ONLY_CONTINUE_IF( + vocabularies.contains_any( + {Vocabularies::Known::JSON_Schema_2020_12_Validation, + Vocabularies::Known::JSON_Schema_2019_09_Validation}) && + schema.is_object() && schema.defines("minContains") && + schema.at("minContains").is_integer() && + schema.defines("maxContains") && + schema.at("maxContains").is_integer() && + schema.at("minContains").to_integer() > + schema.at("maxContains").to_integer()); + return APPLIES_TO_KEYWORDS("minContains", "maxContains"); + } +}; diff --git a/test/alterschema/alterschema_lint_2019_09_test.cc b/test/alterschema/alterschema_lint_2019_09_test.cc index 24c642295..2c3f4a4a6 100644 --- a/test/alterschema/alterschema_lint_2019_09_test.cc +++ b/test/alterschema/alterschema_lint_2019_09_test.cc @@ -2185,6 +2185,189 @@ TEST(AlterSchema_lint_2019_09, unsatisfiable_max_contains_3) { EXPECT_EQ(document, expected); } +TEST(AlterSchema_lint_2019_09, incoherent_min_max_contains_1) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "Test", + "description": "A test schema", + "examples": [ [] ], + "type": "array", + "contains": { "type": "string" }, + "minContains": 5, + "maxContains": 3 + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_FALSE(result.first); + EXPECT_EQ(traces.size(), 1); + EXPECT_LINT_TRACE(traces, 0, "", "incoherent_min_max_contains", + "`minContains` greater than `maxContains` makes the " + "schema unsatisfiable", + false); +} + +TEST(AlterSchema_lint_2019_09, incoherent_min_max_contains_2) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "Test", + "description": "A test schema", + "examples": [ [] ], + "type": "array", + "contains": { "type": "string" }, + "minContains": 3, + "maxContains": 3 + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_TRUE(result.first); + EXPECT_EQ(traces.size(), 0); +} + +TEST(AlterSchema_lint_2019_09, incoherent_min_max_contains_3) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "Test", + "description": "A test schema", + "examples": [ [] ], + "type": "array", + "contains": { "type": "string" }, + "minContains": 1, + "maxContains": 5 + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_TRUE(result.first); + EXPECT_EQ(traces.size(), 0); +} + +TEST(AlterSchema_lint_2019_09, incoherent_min_max_contains_4) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "Test", + "description": "A test schema", + "examples": [ {} ], + "properties": { + "foo": { + "type": "array", + "contains": { "type": "string" }, + "minContains": 5, + "maxContains": 3 + } + } + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_FALSE(result.first); + EXPECT_EQ(traces.size(), 1); + EXPECT_LINT_TRACE(traces, 0, "/properties/foo", "incoherent_min_max_contains", + "`minContains` greater than `maxContains` makes the " + "schema unsatisfiable", + false); +} + +TEST(AlterSchema_lint_2019_09, incoherent_min_max_contains_5) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "Test", + "description": "A test schema", + "examples": [ [] ], + "type": "array", + "contains": { "type": "string" }, + "minContains": 5 + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_TRUE(result.first); + EXPECT_EQ(traces.size(), 0); +} + +TEST(AlterSchema_lint_2019_09, incoherent_min_max_contains_6) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "Test", + "description": "A test schema", + "examples": [ [] ], + "type": "array", + "items": { + "type": "array", + "contains": { "type": "string" }, + "minContains": 5, + "maxContains": 3 + } + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_FALSE(result.first); + EXPECT_EQ(traces.size(), 1); + EXPECT_LINT_TRACE(traces, 0, "/items", "incoherent_min_max_contains", + "`minContains` greater than `maxContains` makes the " + "schema unsatisfiable", + false); +} + +TEST(AlterSchema_lint_2019_09, incoherent_min_max_contains_7) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "Test", + "description": "A test schema", + "examples": [ [] ], + "$ref": "#/$defs/foo", + "$defs": { + "foo": { + "type": "array", + "contains": { "type": "string" }, + "minContains": 5, + "maxContains": 3 + } + } + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_FALSE(result.first); + EXPECT_EQ(traces.size(), 1); + EXPECT_LINT_TRACE(traces, 0, "/$defs/foo", "incoherent_min_max_contains", + "`minContains` greater than `maxContains` makes the " + "schema unsatisfiable", + false); +} + +TEST(AlterSchema_lint_2019_09, incoherent_min_max_contains_8) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "Test", + "description": "A test schema", + "examples": [ {} ], + "$ref": "#/$defs/A/properties/bar", + "$defs": { + "A": { + "type": "array", + "contains": { "type": "string" }, + "minContains": 5, + "maxContains": 3, + "properties": { + "bar": { "type": "string" } + } + } + } + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_FALSE(result.first); + EXPECT_EQ(traces.size(), 1); + EXPECT_LINT_TRACE(traces, 0, "/$defs/A", "incoherent_min_max_contains", + "`minContains` greater than `maxContains` makes the " + "schema unsatisfiable", + false); +} + TEST(AlterSchema_lint_2019_09, equal_numeric_bounds_to_const_1) { sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ "$schema": "https://json-schema.org/draft/2019-09/schema", diff --git a/test/alterschema/alterschema_lint_2020_12_test.cc b/test/alterschema/alterschema_lint_2020_12_test.cc index ad409c356..e56be9c21 100644 --- a/test/alterschema/alterschema_lint_2020_12_test.cc +++ b/test/alterschema/alterschema_lint_2020_12_test.cc @@ -2089,6 +2089,189 @@ TEST(AlterSchema_lint_2020_12, unsatisfiable_max_contains_3) { EXPECT_EQ(document, expected); } +TEST(AlterSchema_lint_2020_12, incoherent_min_max_contains_1) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Test", + "description": "A test schema", + "examples": [ [] ], + "type": "array", + "contains": { "type": "string" }, + "minContains": 5, + "maxContains": 3 + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_FALSE(result.first); + EXPECT_EQ(traces.size(), 1); + EXPECT_LINT_TRACE(traces, 0, "", "incoherent_min_max_contains", + "`minContains` greater than `maxContains` makes the " + "schema unsatisfiable", + false); +} + +TEST(AlterSchema_lint_2020_12, incoherent_min_max_contains_2) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Test", + "description": "A test schema", + "examples": [ [] ], + "type": "array", + "contains": { "type": "string" }, + "minContains": 3, + "maxContains": 3 + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_TRUE(result.first); + EXPECT_EQ(traces.size(), 0); +} + +TEST(AlterSchema_lint_2020_12, incoherent_min_max_contains_3) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Test", + "description": "A test schema", + "examples": [ [] ], + "type": "array", + "contains": { "type": "string" }, + "minContains": 1, + "maxContains": 5 + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_TRUE(result.first); + EXPECT_EQ(traces.size(), 0); +} + +TEST(AlterSchema_lint_2020_12, incoherent_min_max_contains_4) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Test", + "description": "A test schema", + "examples": [ {} ], + "properties": { + "foo": { + "type": "array", + "contains": { "type": "string" }, + "minContains": 5, + "maxContains": 3 + } + } + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_FALSE(result.first); + EXPECT_EQ(traces.size(), 1); + EXPECT_LINT_TRACE(traces, 0, "/properties/foo", "incoherent_min_max_contains", + "`minContains` greater than `maxContains` makes the " + "schema unsatisfiable", + false); +} + +TEST(AlterSchema_lint_2020_12, incoherent_min_max_contains_5) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Test", + "description": "A test schema", + "examples": [ [] ], + "type": "array", + "contains": { "type": "string" }, + "minContains": 5 + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_TRUE(result.first); + EXPECT_EQ(traces.size(), 0); +} + +TEST(AlterSchema_lint_2020_12, incoherent_min_max_contains_6) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Test", + "description": "A test schema", + "examples": [ [] ], + "type": "array", + "items": { + "type": "array", + "contains": { "type": "string" }, + "minContains": 5, + "maxContains": 3 + } + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_FALSE(result.first); + EXPECT_EQ(traces.size(), 1); + EXPECT_LINT_TRACE(traces, 0, "/items", "incoherent_min_max_contains", + "`minContains` greater than `maxContains` makes the " + "schema unsatisfiable", + false); +} + +TEST(AlterSchema_lint_2020_12, incoherent_min_max_contains_7) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Test", + "description": "A test schema", + "examples": [ [] ], + "$ref": "#/$defs/foo", + "$defs": { + "foo": { + "type": "array", + "contains": { "type": "string" }, + "minContains": 5, + "maxContains": 3 + } + } + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_FALSE(result.first); + EXPECT_EQ(traces.size(), 1); + EXPECT_LINT_TRACE(traces, 0, "/$defs/foo", "incoherent_min_max_contains", + "`minContains` greater than `maxContains` makes the " + "schema unsatisfiable", + false); +} + +TEST(AlterSchema_lint_2020_12, incoherent_min_max_contains_8) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Test", + "description": "A test schema", + "examples": [ {} ], + "$ref": "#/$defs/A/properties/bar", + "$defs": { + "A": { + "type": "array", + "contains": { "type": "string" }, + "minContains": 5, + "maxContains": 3, + "properties": { + "bar": { "type": "string" } + } + } + } + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_FALSE(result.first); + EXPECT_EQ(traces.size(), 1); + EXPECT_LINT_TRACE(traces, 0, "/$defs/A", "incoherent_min_max_contains", + "`minContains` greater than `maxContains` makes the " + "schema unsatisfiable", + false); +} + TEST(AlterSchema_lint_2020_12, equal_numeric_bounds_to_const_1) { sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ "$schema": "https://json-schema.org/draft/2020-12/schema", From 22019b2a3cbf2b26a3c974efc376ee78982ac139 Mon Sep 17 00:00:00 2001 From: Vaibhav mittal Date: Fri, 27 Mar 2026 17:45:28 +0000 Subject: [PATCH 2/3] fix(linter): require contains for incoherent_min_max_contains and update tests Signed-off-by: Vaibhav mittal --- .../linter/incoherent_min_max_contains.h | 3 ++- .../alterschema_lint_2019_09_test.cc | 19 +++++++++++++++++++ .../alterschema_lint_2020_12_test.cc | 19 +++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/extension/alterschema/linter/incoherent_min_max_contains.h b/src/extension/alterschema/linter/incoherent_min_max_contains.h index d5d1f5947..7ee0a0011 100644 --- a/src/extension/alterschema/linter/incoherent_min_max_contains.h +++ b/src/extension/alterschema/linter/incoherent_min_max_contains.h @@ -21,7 +21,8 @@ class IncoherentMinMaxContains final : public SchemaTransformRule { vocabularies.contains_any( {Vocabularies::Known::JSON_Schema_2020_12_Validation, Vocabularies::Known::JSON_Schema_2019_09_Validation}) && - schema.is_object() && schema.defines("minContains") && + schema.is_object() && schema.defines("contains") && + schema.defines("minContains") && schema.at("minContains").is_integer() && schema.defines("maxContains") && schema.at("maxContains").is_integer() && diff --git a/test/alterschema/alterschema_lint_2019_09_test.cc b/test/alterschema/alterschema_lint_2019_09_test.cc index 2c3f4a4a6..4b0578927 100644 --- a/test/alterschema/alterschema_lint_2019_09_test.cc +++ b/test/alterschema/alterschema_lint_2019_09_test.cc @@ -2368,6 +2368,25 @@ TEST(AlterSchema_lint_2019_09, incoherent_min_max_contains_8) { false); } +TEST(AlterSchema_lint_2019_09, incoherent_min_max_contains_9) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2019-09/schema", + "title": "Test", + "description": "A test schema", + "examples": [ [] ], + "type": "array", + "minContains": 5, + "maxContains": 3 + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_FALSE(result.first); + for (const auto &trace : traces) { + EXPECT_NE(std::get<1>(trace), "incoherent_min_max_contains"); + } +} + TEST(AlterSchema_lint_2019_09, equal_numeric_bounds_to_const_1) { sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ "$schema": "https://json-schema.org/draft/2019-09/schema", diff --git a/test/alterschema/alterschema_lint_2020_12_test.cc b/test/alterschema/alterschema_lint_2020_12_test.cc index e56be9c21..37d5f5229 100644 --- a/test/alterschema/alterschema_lint_2020_12_test.cc +++ b/test/alterschema/alterschema_lint_2020_12_test.cc @@ -2272,6 +2272,25 @@ TEST(AlterSchema_lint_2020_12, incoherent_min_max_contains_8) { false); } +TEST(AlterSchema_lint_2020_12, incoherent_min_max_contains_9) { + const sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Test", + "description": "A test schema", + "examples": [ [] ], + "type": "array", + "minContains": 5, + "maxContains": 3 + })JSON"); + + LINT_WITHOUT_FIX(document, result, traces); + + EXPECT_FALSE(result.first); + for (const auto &trace : traces) { + EXPECT_NE(std::get<1>(trace), "incoherent_min_max_contains"); + } +} + TEST(AlterSchema_lint_2020_12, equal_numeric_bounds_to_const_1) { sourcemeta::core::JSON document = sourcemeta::core::parse_json(R"JSON({ "$schema": "https://json-schema.org/draft/2020-12/schema", From 96b4a54c5b71872a087ebfde4d5df091256a6c8d Mon Sep 17 00:00:00 2001 From: Vaibhav mittal Date: Sun, 5 Apr 2026 17:51:56 +0000 Subject: [PATCH 3/3] fix(linter): address review feedback for incoherent_min_max_contains Signed-off-by: Vaibhav mittal --- test/alterschema/alterschema_lint_2019_09_test.cc | 12 +++++++++--- test/alterschema/alterschema_lint_2020_12_test.cc | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/test/alterschema/alterschema_lint_2019_09_test.cc b/test/alterschema/alterschema_lint_2019_09_test.cc index 4b0578927..e3ef2ef9c 100644 --- a/test/alterschema/alterschema_lint_2019_09_test.cc +++ b/test/alterschema/alterschema_lint_2019_09_test.cc @@ -2382,9 +2382,15 @@ TEST(AlterSchema_lint_2019_09, incoherent_min_max_contains_9) { LINT_WITHOUT_FIX(document, result, traces); EXPECT_FALSE(result.first); - for (const auto &trace : traces) { - EXPECT_NE(std::get<1>(trace), "incoherent_min_max_contains"); - } + EXPECT_EQ(traces.size(), 2); + EXPECT_LINT_TRACE(traces, 0, "", "max_contains_without_contains", + "The `maxContains` keyword is meaningless " + "without the presence of the `contains` keyword", + true); + EXPECT_LINT_TRACE(traces, 1, "", "min_contains_without_contains", + "The `minContains` keyword is meaningless " + "without the presence of the `contains` keyword", + true); } TEST(AlterSchema_lint_2019_09, equal_numeric_bounds_to_const_1) { diff --git a/test/alterschema/alterschema_lint_2020_12_test.cc b/test/alterschema/alterschema_lint_2020_12_test.cc index 37d5f5229..b303726c3 100644 --- a/test/alterschema/alterschema_lint_2020_12_test.cc +++ b/test/alterschema/alterschema_lint_2020_12_test.cc @@ -2286,9 +2286,15 @@ TEST(AlterSchema_lint_2020_12, incoherent_min_max_contains_9) { LINT_WITHOUT_FIX(document, result, traces); EXPECT_FALSE(result.first); - for (const auto &trace : traces) { - EXPECT_NE(std::get<1>(trace), "incoherent_min_max_contains"); - } + EXPECT_EQ(traces.size(), 2); + EXPECT_LINT_TRACE(traces, 0, "", "max_contains_without_contains", + "The `maxContains` keyword is meaningless " + "without the presence of the `contains` keyword", + true); + EXPECT_LINT_TRACE(traces, 1, "", "min_contains_without_contains", + "The `minContains` keyword is meaningless " + "without the presence of the `contains` keyword", + true); } TEST(AlterSchema_lint_2020_12, equal_numeric_bounds_to_const_1) {