lint: add incoherent_exclusive_limits rule#818
Conversation
🤖 Augment PR SummarySummary: This PR introduces a new linter rule, Changes:
Technical Notes: The rule is non-mutating (lint-only) and reports both keywords as implicated when it fires. 🤖 Was this summary useful? React with 👍 or 👎 |
There was a problem hiding this comment.
2 issues found across 6 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
efb534c to
bffc321
Compare
jviotti
left a comment
There was a problem hiding this comment.
I think this one is a good one to have in the common folder for both the linter and canonicalizer (and testing on both), and having a transform that sets the current schema to false or just adds not: true or whatever to make the unsatisfiability obvious.
I believe we have some rules that do something like this for you to take inspiration from
ed368af to
d8625e0
Compare
|
Updated as suggested. The rule is now in |
| } | ||
|
|
||
| auto transform(JSON &schema, const Result &) const -> void override { | ||
| schema.into(JSON{false}); |
There was a problem hiding this comment.
Much better, but you will have to guard against $ref's. Consider a schema like this, which is weird, but definitely valid:
{
"$ref": "#/$defs/test/additionalProperties"
"$defs": {
"test": {
"additionalProperties": {
"type": "string"
},
"exclusiveMaximum": 5,
"exclusiveMinimum": 8
}
}
}By unconditionally converting $defs/test to false you break the reference. So try to think of potential adversarial tests for how you can break the rule. Also maybe if the schema has a $dynamicAnchor, etc.
If I recall correctly, in other cases, for this reason, what we do is NOT convert the whole thing to false but just add not: {}, or if not is already there, add it into an existing or new allOf
edb10ef to
8de48ab
Compare
|
Thanks for the detailed feedback. I have updated the implementation to address all your points: Rule changes (
Test coverage:
All 39 unit tests are passing cleanly and the full alterschema test suite has been verified. |
| Vocabularies::Known::JSON_Schema_Draft_6})); | ||
| // If the schema is already marked as unsatisfiable by a previous transform, | ||
| // stop | ||
| if (schema.defines("not") && is_empty_schema(schema.at("not"))) { |
There was a problem hiding this comment.
You need to be careful when referring to not or allOf in the condition. In 2020-12 and 2019-09, those keywords are only valid if the Applicator vocabulary is also in effect!
| if (schema.defines("not") && is_empty_schema(schema.at("not"))) { | ||
| return false; | ||
| } | ||
| if (schema.defines("allOf") && schema.at("allOf").is_array()) { |
There was a problem hiding this comment.
Do we really need this check though? Adding a not outside the allOf is still not bad?
| } | ||
| } | ||
| // Don't mutate schemas that are reference targets | ||
| if (schema.defines("$anchor") || schema.defines("$dynamicAnchor")) { |
There was a problem hiding this comment.
I think you might be overprotecting through. As long as you didn't i.e. turn the entire subschema to false and therefore removed keywords as a byproduct, just letting be while you add not: {} is probably fine and won't break any anchor or reference?
8de48ab to
efee04f
Compare
|
Thanks for the follow-up feedback ,You are completely correct, and I have simplified the implementation to address all concerns:
I also cleaned up the test suites by removing the redundant anchor-related tests since the rules are now simpler and rely on standard framework-level anchor preservation. All 36 integration and linter tests are passing successfully. |
|
|
||
| const auto expected = sourcemeta::core::parse_json(R"JSON({ | ||
| "$schema": "https://json-schema.org/draft/2019-09/schema", | ||
| "not": true |
There was a problem hiding this comment.
Oh, I just realised something. This transformation is not fully semantically correct. The schema is only unsatisfiable if the instance is a number, right? Otherwise the exclusive* ones don't really apply. Can you do another pass on this? Let's try to make sure all transformations keep 100% of the schema semantics in that.
I guess in this case we can turn the not to not: { type: number } or something?
I think we are getting into something here, but as you can see, its very easy to mess up these transformations
There was a problem hiding this comment.
You're right, Since exclusive limits only apply to numbers, other types should not be affected.
I updated the transform to use not: { type: number } instead of a generic not: {} so the unsatisfiability only targets numbers.
If the schema already has a type that excludes numbers (e.g. type: "object"), the exclusive limits can never apply to any valid instance, so we just erase them without adding a redundant not.
All tests updated and passing cleanly.
efee04f to
947ec43
Compare
| auto inner = JSON::make_object(); | ||
| inner.assign("type", JSON{"number"}); | ||
|
|
||
| if (!schema.defines("not") && !schema.defines("allOf")) { |
There was a problem hiding this comment.
Note that you are unsafely using these keywords for 2019-09 and 2020-12. You have only checked for the validation vocabulary in that case. I think you need to tweak the condition so that this rule only applies on those in BOTH the applicator and validation vocabularies are set
|
|
||
| auto transform(JSON &schema, const Result &) const -> void override { | ||
| bool needs_not = true; | ||
| if (schema.defines("type")) { |
There was a problem hiding this comment.
Can we have a test that covers a type: number + the exclusive impossibility and see how that gets auto fixed? and same with type: integer? I wonder if that will be a funny one. I guess we want to then DELETE the type and move it into the not instead?
| inner.assign("type", JSON{"number"}); | ||
|
|
||
| if (!schema.defines("not") && !schema.defines("allOf")) { | ||
| schema.assign("not", std::move(inner)); |
There was a problem hiding this comment.
This and other cases means that we put such new property at the end of the object. Which can look weird if there is more schema content than just the exclusive* keywords. I think you need to assign these ones RIGHT AFTER the exclusive ones (we have JSON:: helpers for this, like try_emplace_back I think) when possible. Otherwise while valid, the auto-fixer will give you a weird JSON object with trailing allOf and not's
fc0f132 to
3a82e9f
Compare
Signed-off-by: AcE <kintan0108@gmail.com>
3a82e9f to
2882172
Compare
Summary
Adds a new lint rule
incoherent_exclusive_limitsthat fires whenexclusiveMinimumis greater than or equal toexclusiveMaximum, making the schema unsatisfiable.Applies to Draft 6, Draft 7, 2019-09, and 2020-12.
Test cases (per dialect)
exclusiveMinimum<exclusiveMaximumexclusiveMinimumpresentexclusiveMaximumpresentexclusiveMinimum==exclusiveMaximumexclusiveMinimum>exclusiveMaximumChanges
src/alterschema/linter/incoherent_exclusive_limits.h- new rulesrc/alterschema/alterschema.cc- rule registered for all four dialectssrc/alterschema/CMakeLists.txt- header addedtest/alterschema/alterschema_lint_draft6_test.cc- tests addedtest/alterschema/alterschema_lint_draft7_test.cc- tests addedtest/alterschema/alterschema_lint_2019_09_test.cc- tests addedtest/alterschema/alterschema_lint_2020_12_test.cc- tests addedPart of sourcemeta/core#1975.