liminal 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 7c8b9358afd48ae00ba175c322616177fea6b11b28b68039866960250ecac6ad
4
+ data.tar.gz: 7336f7cf8458cb1da841cc08b3647df2345cbea98849ed87dbca9f118bdb9de5
5
+ SHA512:
6
+ metadata.gz: e2fb7bae0338e67a6971d6aa072e310be23c3dae0f948fbc91ff0b067135e99f51a667c700a3a08b5de4d769ea02e7b448adde5fb61d3d5d25ca886c4d66d5bd
7
+ data.tar.gz: 35b0ba788a0b564d7b0fb65e9e674653515f4117122c90321240ca5767d773cddad697488dd121fc12a5d53f89ed81c2aaaf04e4e681946da763608b4044bafd
data/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes are documented here. The project follows Semantic Versioning.
4
+
5
+ ## 0.1.0 - Unreleased
6
+
7
+ - Add strict dry-schema 1.x AST compilation to OpenAPI 3.0 Schema Objects.
8
+ - Add exclusions, enrichment and authoritative overrides, and immutable predicate extensions.
9
+ - Add idempotent OpenAPI component registration with collision detection.
10
+ - Compile the standard dry-types boolean union emitted as `true?`/`false?` value predicates.
11
+ - Add strict Schema Object normalization and non-mutating `required`, `exactly_one`, and `all_of` composition.
12
+ - Reject ambiguous paths and keys, non-JSON numeric values, unsafe regular-expression anchors, and malformed metadata objects.
data/CONTRIBUTING.md ADDED
@@ -0,0 +1,26 @@
1
+ # Contributing
2
+
3
+ Liminal intentionally supports only declarative behavior visible in `Dry::Schema#to_ast`. Changes that infer or execute
4
+ application rules are outside the project boundary.
5
+
6
+ Install dependencies and run the default checks:
7
+
8
+ ```sh
9
+ bundle install
10
+ bundle exec rake
11
+ ```
12
+
13
+ Run a dry-schema compatibility appraisal by selecting its Gemfile:
14
+
15
+ ```sh
16
+ BUNDLE_GEMFILE=gemfiles/dry_schema_1_13.gemfile bundle install
17
+ BUNDLE_GEMFILE=gemfiles/dry_schema_1_13.gemfile bundle exec rspec
18
+ ```
19
+
20
+ Repeat for `dry_schema_1_14`, `dry_schema_1_15`, and `dry_schema_1_16`. Tests use randomized order. Enable line and branch
21
+ coverage locally with `COVERAGE=true bundle exec rspec`.
22
+
23
+ Before proposing a new predicate, show that its semantics can be represented safely in OpenAPI 3.0 and add real
24
+ dry-schema AST coverage. Do not publish, release, or widen the supported dependency range without the maintainers'
25
+ explicit approval and a green compatibility matrix.
26
+
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Liminal contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
data/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # Liminal
2
+
3
+ Liminal strictly compiles declarative `Dry::Schema#to_ast` structure and predicates into deterministic OpenAPI 3.0
4
+ Schema Objects. It is useful when a dry-schema definition is the application source of truth and silently dropping a
5
+ validation constraint would make generated API documentation misleading.
6
+
7
+ The Ruby API lives under `Liminal`; it does not use `Dry::` and is not an official dry-rb project.
8
+
9
+ ## Compatibility
10
+
11
+ | Concern | Supported |
12
+ | --- | --- |
13
+ | Ruby | 3.2 and newer |
14
+ | dry-schema | 1.13.4 through 1.16.x (`>= 1.13.4`, `< 1.17`) |
15
+ | OpenAPI | 3.0.x only |
16
+ | OpenAPI integration | Provided separately by the `liminal-openapi` companion gem |
17
+
18
+ The dependency matrix runs the same real dry-schema definitions against 1.13, 1.14, 1.15, and 1.16. OpenAPI 3.1 syntax,
19
+ including type arrays and numeric `exclusiveMinimum` values, is rejected.
20
+
21
+ ## Installation
22
+
23
+ Add the gem to your bundle:
24
+
25
+ ```ruby
26
+ gem "liminal", "~> 0.1.0"
27
+ ```
28
+
29
+ Then run `bundle install` and require `liminal`.
30
+
31
+ ## Compilation
32
+
33
+ ```ruby
34
+ require "dry/schema"
35
+ require "liminal"
36
+
37
+ schema = Dry::Schema.Params do
38
+ required(:name).filled(:string)
39
+ optional(:score).filled(:float, gt?: 0)
40
+ end
41
+
42
+ openapi_schema = Liminal.compile(
43
+ schema,
44
+ dialect: :openapi_3_0,
45
+ source_name: "CreateEventSchema"
46
+ )
47
+ ```
48
+
49
+ The result is a plain recursively string-keyed `Hash`. Required fields become `required`; optional fields do not.
50
+ Arrays always include `items`, empty `required` arrays are omitted, and stable AST order produces stable output.
51
+
52
+ Liminal depends on `dry-schema`, not `dry-validation`. Applications using contracts can expose a convenience method:
53
+
54
+ ```ruby
55
+ class Contracts::Base < Dry::Validation::Contract
56
+ def self.openapi_schema(**options)
57
+ Liminal.compile(schema, source_name: name, **options)
58
+ end
59
+ end
60
+ ```
61
+
62
+ `Dry::Schema::Params` may coerce input strings before validation, while OpenAPI describes their post-coercion JSON
63
+ representation. Use `Dry::Schema::JSON` when differential tests should compare non-coercing behavior directly.
64
+
65
+ ## Exclusions
66
+
67
+ Array paths are canonical and preserve property names containing dots:
68
+
69
+ ```ruby
70
+ Liminal.compile(
71
+ schema,
72
+ exclude: [[:message_id], %i[data internal_metadata]]
73
+ )
74
+ ```
75
+
76
+ Exclusions happen before the field rule is compiled, remove the field from its parent's `required` list, and can conceal
77
+ an intentionally undocumented unsupported predicate. Unknown paths raise `Liminal::UnknownExclusionPathError`. Dotted
78
+ strings are accepted only as a convenience.
79
+
80
+ ## Overrides
81
+
82
+ An enrichment override deep-merges documentation or representable structural details into a successfully generated field:
83
+
84
+ ```ruby
85
+ overrides = {
86
+ %i[data attributes event_at] => {
87
+ "format" => "date",
88
+ "description" => "Calendar date"
89
+ }
90
+ }
91
+ ```
92
+
93
+ `required` arrays are unioned during enrichment. An authoritative override may replace a field whose node or predicate
94
+ cannot be compiled, but it must contain one of `type`, `$ref`, `oneOf`, `anyOf`, or `allOf`:
95
+
96
+ ```ruby
97
+ overrides = {
98
+ [:opaque_id] => {"type" => "string", "format" => "uuid"}
99
+ }
100
+ ```
101
+
102
+ A description alone never hides an unsupported construct. Unknown paths raise `Liminal::UnknownOverridePathError`.
103
+ An override cannot target an excluded path or one of its descendants; contradictory path configuration raises
104
+ `Liminal::InvalidOverrideError`. A parent override may enrich a schema after one of its descendants is excluded.
105
+ OpenAPI 3.1-only keywords are rejected. OpenAPI 3.0 forbids `$ref` siblings, so use `allOf` when a reference also needs a
106
+ description:
107
+
108
+ ```ruby
109
+ { "allOf" => [{ "$ref" => "#/components/schemas/User" }], "description" => "User" }
110
+ ```
111
+
112
+ ## Schema composition
113
+
114
+ `Liminal::Schema.normalize` is the public boundary for precompiled Schema
115
+ Objects. It copies the input, normalizes object keys, validates OpenAPI 3.0
116
+ keywords and nested values, and produces deterministic keyword order:
117
+
118
+ ```ruby
119
+ normalized = Liminal::Schema.normalize({type: "string"})
120
+ required = Liminal::Schema.required(schema, :mobile_phone)
121
+ exclusive = Liminal::Schema.exactly_one(schema, :mobile_phone, :email)
122
+ combined = Liminal::Schema.all_of(base_schema, detail_schema)
123
+ ```
124
+
125
+ Precompiled values must already be JSON-native: strings, booleans, `nil`,
126
+ integers, finite floats, arrays, and string- or symbol-keyed hashes. Ambiguous
127
+ string/symbol keys and Ruby-only scalar values fail validation instead of being
128
+ silently coerced.
129
+
130
+ `required` unions the named properties with an existing `required` array.
131
+ `exactly_one` emits one `oneOf` required-property branch per name and preserves
132
+ an existing `oneOf` as a separate `allOf` constraint. `all_of` keeps each input
133
+ as an independent branch. Applying a combinator to a `$ref` uses `allOf`, since
134
+ OpenAPI 3.0 forbids `$ref` siblings.
135
+
136
+ ## Predicate extensions
137
+
138
+ The default registry is immutable. `with` returns a copy:
139
+
140
+ ```ruby
141
+ predicates = Liminal::PredicateRegistry.default.with(:uuid_v4?) do |_arguments, context|
142
+ raise "unexpected dialect" unless context.dialect.name == :openapi_3_0
143
+
144
+ {"type" => "string", "format" => "uuid"}
145
+ end
146
+
147
+ Liminal.compile(schema, predicates: predicates)
148
+ ```
149
+
150
+ Handlers receive semantic arguments with dry input placeholders removed and a context containing the canonical path and
151
+ dialect. They must return a Hash. Duplicate registration requires `replace: true`; incompatible keywords and types are
152
+ rejected.
153
+
154
+ Built-in predicates cover arrays, booleans, dates and times, numbers, hashes, integers, strings, `type?` for the documented
155
+ Ruby primitive classes, equality and inclusion enums, safe regular expressions, filled and size constraints, numeric
156
+ boundaries, and integer parity. `and`, `or`, nullable implications, and `each` are compiled without exposing raw AST nodes.
157
+ Ruby `\A` and `\z` anchors are translated to their ECMA-262 equivalents. Raw `^`
158
+ and `$` anchors are rejected because their Ruby line semantics cannot be
159
+ preserved safely.
160
+
161
+ ## Strict failures and custom rules
162
+
163
+ Unsupported AST nodes and predicates raise contextual `Liminal::UnsupportedNodeError` or
164
+ `Liminal::UnsupportedPredicateError`, including the source, canonical field path, and failing operation. Compilation never
165
+ has a permissive mode.
166
+
167
+ A dry-validation rule such as this is arbitrary Ruby and is not part of the declarative schema AST:
168
+
169
+ ```ruby
170
+ rule("data.attributes.event_at") do
171
+ ValidateIso8601Date.call(date: value)
172
+ end
173
+ ```
174
+
175
+ Liminal does not execute, introspect, or pretend to translate such rules. Represent structural documentation explicitly
176
+ with an override. Database checks and cross-field business rules belong in endpoint prose rather than a Schema Object.
177
+
178
+ ## Components
179
+
180
+ Register a generated component in any mutable OpenAPI document:
181
+
182
+ ```ruby
183
+ reference = Liminal::Components.register(
184
+ document: openapi_document,
185
+ name: :calendar_event,
186
+ schema: openapi_schema
187
+ )
188
+ # => {"$ref"=>"#/components/schemas/calendar_event"}
189
+ ```
190
+
191
+ Missing maps are created. A document may use either string or symbol container
192
+ keys, but declaring both logical forms is rejected as ambiguous. Component
193
+ names may contain only letters, digits, periods, hyphens, and underscores.
194
+ Re-registering the same normalized schema is idempotent; a different schema or
195
+ duplicate string/symbol component key raises `Liminal::ComponentCollisionError`.
196
+ Returned references are immutable.
197
+
198
+ ## Rswag boundary
199
+
200
+ The core gem contains no Rails, Active Support, RSpec, or Rswag runtime dependency and performs no global monkey-patching.
201
+ Until a separately versioned Rswag companion is requested, an application-side opt-in helper can keep the integration at
202
+ the metadata boundary:
203
+
204
+ ```ruby
205
+ def request_body_from_schema(schema:, component:, parameter_name:, source_name: nil, **options)
206
+ generated = Liminal.compile(schema, source_name: source_name, **options)
207
+ reference = Liminal::Components.register(
208
+ document: selected_openapi_document,
209
+ name: component,
210
+ schema: generated
211
+ )
212
+
213
+ parameter name: parameter_name, in: :body, required: true, schema: reference
214
+ end
215
+ ```
216
+
217
+ Application action-to-contract metadata remains outside Liminal. Request examples remain Rswag metadata and are not
218
+ embedded into structural schemas.
219
+
220
+ ## Development
221
+
222
+ Run `bundle exec rake` for randomized RSpec and RuboCop checks, `COVERAGE=true bundle exec rspec` for line and branch
223
+ coverage, and `bundle exec rake build` to build the gem locally. The development suite validates a generated minimal
224
+ document with `openapi3_parser` and compares representative non-coercing payload behavior with `json_schemer`.
225
+
226
+ The project is versioned from `0.1.0` while its extension API is being proven. No release or publication is performed by
227
+ the implementation workflow.
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Liminal
4
+ module Adapters
5
+ # Converts the documented dry-schema 1.x AST into semantic nodes. No raw
6
+ # AST layout escapes this adapter.
7
+ class DrySchemaV1
8
+ SetNode = Struct.new(:children, keyword_init: true)
9
+ FieldNode = Struct.new(:name, :required, :rule, keyword_init: true)
10
+ PredicateNode = Struct.new(:name, :arguments, keyword_init: true)
11
+ AndNode = Struct.new(:children, keyword_init: true)
12
+ OrNode = Struct.new(:children, keyword_init: true)
13
+ NullableNode = Struct.new(:rule, keyword_init: true)
14
+ ImplicationNode = Struct.new(:left, :right, keyword_init: true)
15
+ NotNode = Struct.new(:rule, keyword_init: true)
16
+ EachNode = Struct.new(:rule, keyword_init: true)
17
+ KeyNode = Struct.new(:name, :rule, keyword_init: true)
18
+ UnknownNode = Struct.new(:operation, keyword_init: true)
19
+
20
+ def call(ast)
21
+ visit(ast)
22
+ end
23
+
24
+ private
25
+
26
+ def visit(ast)
27
+ return UnknownNode.new(operation: :invalid_ast) unless ast.is_a?(Array) && ast.length == 2
28
+
29
+ operation, payload = ast
30
+ case operation
31
+ when :set then SetNode.new(children: array_payload(payload).map { |child| visit(child) })
32
+ when :and then visit_and(payload)
33
+ when :or then OrNode.new(children: array_payload(payload).map { |child| visit(child) })
34
+ when :implication then visit_implication(payload)
35
+ when :not then NotNode.new(rule: visit(payload))
36
+ when :each then EachNode.new(rule: visit(payload))
37
+ when :key then visit_key(payload)
38
+ when :predicate then visit_predicate(payload)
39
+ else UnknownNode.new(operation: operation || :invalid_ast)
40
+ end
41
+ end
42
+
43
+ def visit_and(payload)
44
+ children = array_payload(payload)
45
+ field = field_from_wrapper(children, required: true)
46
+ field || AndNode.new(children: children.map { |child| visit(child) })
47
+ end
48
+
49
+ def visit_implication(payload)
50
+ children = array_payload(payload)
51
+ field = field_from_wrapper(children, required: false)
52
+ return field if field
53
+ return NullableNode.new(rule: visit(children[1])) if nullable_guard?(children[0]) && children.length == 2
54
+
55
+ ImplicationNode.new(left: visit(children[0]), right: visit(children[1]))
56
+ end
57
+
58
+ def field_from_wrapper(children, required:)
59
+ return unless children.length == 2
60
+
61
+ name = key_presence_name(children[0])
62
+ key_ast = children[1]
63
+ return unless name && raw_operation(key_ast) == :key
64
+
65
+ key_name, rule_ast = key_ast[1]
66
+ return unless key_name.to_s == name.to_s
67
+
68
+ FieldNode.new(name: key_name.to_s, required: required, rule: visit(rule_ast))
69
+ end
70
+
71
+ def nullable_guard?(ast)
72
+ return false unless raw_operation(ast) == :not
73
+
74
+ predicate = ast[1]
75
+ raw_operation(predicate) == :predicate && predicate.dig(1, 0) == :nil?
76
+ end
77
+
78
+ def key_presence_name(ast)
79
+ return unless raw_operation(ast) == :predicate
80
+
81
+ name, tagged_arguments = ast[1]
82
+ return unless name == :key?
83
+
84
+ tagged_arguments.find { |tag, _value| tag == :name }&.last
85
+ end
86
+
87
+ def visit_key(payload)
88
+ name, rule = payload
89
+ KeyNode.new(name: name.to_s, rule: visit(rule))
90
+ end
91
+
92
+ def visit_predicate(payload)
93
+ name, tagged_arguments = payload
94
+ arguments = Array(tagged_arguments).filter_map do |tag, value|
95
+ value unless input_placeholder?(name, tag)
96
+ end
97
+ PredicateNode.new(name: name.to_sym, arguments: arguments.freeze)
98
+ end
99
+
100
+ def input_placeholder?(predicate, tag)
101
+ tag == :input ||
102
+ (predicate == :eql? && tag == :right) ||
103
+ (%i[true? false?].include?(predicate) && tag == :value)
104
+ end
105
+
106
+ def raw_operation(ast)
107
+ ast[0] if ast.is_a?(Array)
108
+ end
109
+
110
+ def array_payload(payload)
111
+ payload.is_a?(Array) ? payload : []
112
+ end
113
+ end
114
+ end
115
+ end