schematist 0.1.0 → 1.0.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.
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ module Helpers
5
+ def schema(name = nil, description: nil, &block)
6
+ schema_class = Schema.create(&block)
7
+ schema_class.new(name, description: description)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ module JsonOutput
5
+ DRAFT_2020_12 = "https://json-schema.org/draft/2020-12/schema"
6
+
7
+ # A Draft 2020-12 JSON Schema document, string-keyed and ready for any JSON Schema validator
8
+ def to_json_schema
9
+ validate! # Validate schema before generating JSON
10
+
11
+ document = schema_body
12
+ class_description = document.delete(:description)
13
+ header = {
14
+ "$schema" => DRAFT_2020_12,
15
+ title: document.delete(:title) || @name,
16
+ description: @description || class_description
17
+ }.compact
18
+
19
+ json_compatible(resolve_runtime_values(header.merge(document)))
20
+ end
21
+
22
+ def to_json(*_args)
23
+ validate! # Validate schema before generating JSON string
24
+ JSON.pretty_generate(to_json_schema)
25
+ end
26
+
27
+ private
28
+
29
+ def schema_body
30
+ schema_hash = {
31
+ type: "object",
32
+ properties: self.class.properties,
33
+ required: self.class.required_properties,
34
+ additionalProperties: self.class.additional_properties
35
+ }
36
+
37
+ # Only include $defs if there are definitions
38
+ schema_hash["$defs"] = self.class.definitions unless self.class.definitions.empty?
39
+
40
+ self.class.send(:merge_schema_keywords, schema_hash, self.class)
41
+
42
+ schema_hash
43
+ end
44
+
45
+ # JSON Schema documents are string-keyed. Symbols survive a Ruby comparison but not a JSON round trip.
46
+ def json_compatible(value)
47
+ case value
48
+ when Hash
49
+ value.to_h { |key, nested_value| [key.to_s, json_compatible(nested_value)] }
50
+ when Array
51
+ value.map { |nested_value| json_compatible(nested_value) }
52
+ when Symbol
53
+ value.to_s
54
+ else
55
+ value
56
+ end
57
+ end
58
+
59
+ # Values declared as procs are resolved here, so one schema class can render differently per instance
60
+ def resolve_runtime_values(value)
61
+ case value
62
+ when Proc
63
+ resolve_runtime_values(value.arity.zero? ? instance_exec(&value) : value.call(self))
64
+ when Hash
65
+ value.transform_values { |nested_value| resolve_runtime_values(nested_value) }
66
+ when Array
67
+ value.map { |nested_value| resolve_runtime_values(nested_value) }
68
+ else
69
+ value
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ class Schema
5
+ extend DSL
6
+ include JsonOutput
7
+
8
+ class << self
9
+ def create(&block)
10
+ schema_class = Class.new(Schema)
11
+ schema_class.class_eval(&block)
12
+ schema_class
13
+ end
14
+
15
+ def properties
16
+ @properties ||= {}
17
+ end
18
+
19
+ def required_properties
20
+ @required_properties ||= []
21
+ end
22
+
23
+ def definitions
24
+ @definitions ||= {}
25
+ end
26
+
27
+ def name(name = nil)
28
+ @schema_name = name if name
29
+ return @schema_name if defined?(@schema_name)
30
+
31
+ super()
32
+ end
33
+
34
+ def annotations
35
+ @annotations ||= {}
36
+ end
37
+
38
+ def title(*args)
39
+ annotation(:title, *args)
40
+ end
41
+
42
+ def description(*args)
43
+ annotation(:description, *args)
44
+ end
45
+
46
+ def default(*args)
47
+ annotation(:default, *args)
48
+ end
49
+
50
+ def examples(*args)
51
+ annotation(:examples, *args)
52
+ end
53
+
54
+ def deprecated(*args)
55
+ annotation(:deprecated, *args)
56
+ end
57
+
58
+ def read_only(*args)
59
+ annotation(:read_only, *args)
60
+ end
61
+
62
+ def write_only(*args)
63
+ annotation(:write_only, *args)
64
+ end
65
+
66
+ def core_keywords
67
+ @core_keywords ||= {}
68
+ end
69
+
70
+ def id(*args)
71
+ core_keyword(:id, *args)
72
+ end
73
+
74
+ def anchor(*args)
75
+ core_keyword(:anchor, *args)
76
+ end
77
+
78
+ def comment(*args)
79
+ core_keyword(:comment, *args)
80
+ end
81
+
82
+ def dynamic_anchor(*args)
83
+ core_keyword(:dynamic_anchor, *args)
84
+ end
85
+
86
+ def dynamic_ref(*args)
87
+ core_keyword(:dynamic_ref, *args)
88
+ end
89
+
90
+ def vocabulary(*args)
91
+ core_keyword(:vocabulary, *args)
92
+ end
93
+
94
+ def additional_properties(value = nil)
95
+ return @additional_properties ||= false if value.nil?
96
+
97
+ @additional_properties = value
98
+ end
99
+
100
+ def validate!
101
+ validator = Validator.new(self)
102
+ validator.validate!
103
+ end
104
+
105
+ def valid?
106
+ validator = Validator.new(self)
107
+ validator.valid?
108
+ end
109
+
110
+ private
111
+
112
+ def annotation(name, *args)
113
+ read_or_write(annotations, ANNOTATIONS.fetch(name), *args)
114
+ end
115
+
116
+ def core_keyword(name, *args)
117
+ read_or_write(core_keywords, CORE_KEYWORDS.fetch(name), *args)
118
+ end
119
+
120
+ def read_or_write(store, keyword, *args)
121
+ return store[keyword] if args.empty?
122
+
123
+ store[keyword] = args.first
124
+ end
125
+ end
126
+
127
+ def initialize(name = nil, description: nil)
128
+ @name = name || self.class.name || "Schema"
129
+ @description = description
130
+ end
131
+
132
+ def validate!
133
+ self.class.validate!
134
+ end
135
+
136
+ def valid?
137
+ self.class.valid?
138
+ end
139
+
140
+ def method_missing(method_name, ...)
141
+ if respond_to_missing?(method_name)
142
+ self.class.send(method_name, ...)
143
+ else
144
+ super
145
+ end
146
+ end
147
+
148
+ def respond_to_missing?(method_name, include_private = false)
149
+ %i[string number integer boolean array tuple object any_of one_of all_of none_of raw null].include?(method_name) || super
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Schematist
4
+ class Validator
5
+ # Node states for DFS-based topological sort
6
+ WHITE = :white # No mark (unvisited)
7
+ GRAY = :gray # Temporary mark (currently being processed)
8
+ BLACK = :black # Permanent mark (completely processed)
9
+
10
+ def initialize(schema_class)
11
+ @schema_class = schema_class
12
+ end
13
+
14
+ def validate!
15
+ validate_circular_references!
16
+ # Future validations can be added here
17
+ end
18
+
19
+ def valid?
20
+ validate!
21
+ true
22
+ rescue ValidationError
23
+ false
24
+ end
25
+
26
+ private
27
+
28
+ def validate_circular_references!
29
+ definitions = @schema_class.definitions
30
+ return if definitions.empty?
31
+
32
+ # Initialize all nodes as WHITE (no mark)
33
+ marks = Hash.new { WHITE }
34
+
35
+ # Visit each unmarked node
36
+ definitions.each_key do |node|
37
+ visit(node, definitions, marks) if marks[node] == WHITE
38
+ end
39
+ end
40
+
41
+ # DFS visit function
42
+ def visit(node, definitions, marks)
43
+ # If node has a permanent mark, return
44
+ return if marks[node] == BLACK
45
+
46
+ # If node has a temporary mark, we found a cycle
47
+ raise ValidationError, "Circular reference detected involving '#{node}'" if marks[node] == GRAY
48
+
49
+ # Mark node with temporary mark
50
+ marks[node] = GRAY
51
+
52
+ # Visit all adjacent nodes (dependencies)
53
+ definition = definitions[node]
54
+ if definition && definition[:properties]
55
+ definition[:properties].each_value do |property|
56
+ references = extract_references(property)
57
+ references.each do |adjacent_node|
58
+ visit(adjacent_node, definitions, marks)
59
+ end
60
+ end
61
+ end
62
+
63
+ # Mark node with permanent mark
64
+ marks[node] = BLACK
65
+ end
66
+
67
+ def extract_references(property)
68
+ references = []
69
+
70
+ case property
71
+ when Hash
72
+ if property["$ref"]
73
+ # Extract definition name from reference like "#/$defs/user"
74
+ ref_name = property["$ref"].split("/").last&.to_sym
75
+ references << ref_name if ref_name
76
+ else
77
+ # Recursively check nested properties
78
+ property.each_value do |value|
79
+ references.concat(extract_references(value))
80
+ end
81
+ end
82
+ when Array
83
+ property.each do |item|
84
+ references.concat(extract_references(item))
85
+ end
86
+ end
87
+
88
+ references
89
+ end
90
+ end
91
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Schematist
4
- VERSION = '0.1.0'
4
+ VERSION = '1.0.0'
5
5
  end
data/lib/schematist.rb CHANGED
@@ -1,4 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'schematist/version'
4
- require 'ruby_llm/schema'
3
+ require "json"
4
+
5
+ require_relative "schematist/version"
6
+ require_relative "schematist/errors"
7
+
8
+ module Schematist
9
+ PRIMITIVE_TYPES = %i[string number integer boolean null].freeze
10
+
11
+ # Annotations describe a schema for humans and tools. They carry no validation weight.
12
+ ANNOTATIONS = {
13
+ title: :title,
14
+ description: :description,
15
+ default: :default,
16
+ examples: :examples,
17
+ deprecated: :deprecated,
18
+ read_only: :readOnly,
19
+ write_only: :writeOnly
20
+ }.freeze
21
+
22
+ # Core keywords identify a schema and point at other schemas.
23
+ CORE_KEYWORDS = {
24
+ id: "$id",
25
+ anchor: "$anchor",
26
+ comment: "$comment",
27
+ dynamic_anchor: "$dynamicAnchor",
28
+ dynamic_ref: "$dynamicRef",
29
+ vocabulary: "$vocabulary"
30
+ }.freeze
31
+ end
32
+
33
+ # Every file defines the constant its path implies. The DSL modules come before dsl.rb,
34
+ # which includes them, and the DSL before schema.rb, which extends it.
35
+ require_relative "schematist/dsl/schema_builders"
36
+ require_relative "schematist/dsl/primitive_types"
37
+ require_relative "schematist/dsl/complex_types"
38
+ require_relative "schematist/dsl/conditional_builder"
39
+ require_relative "schematist/dsl/conditional_context"
40
+ require_relative "schematist/dsl/conditionals"
41
+ require_relative "schematist/dsl/utilities"
42
+ require_relative "schematist/dsl"
43
+ require_relative "schematist/json_output"
44
+ require_relative "schematist/validator"
45
+ require_relative "schematist/helpers"
46
+ require_relative "schematist/schema"
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :release do
4
+ desc 'Prepare for release'
5
+ task :prepare do
6
+ sh 'overcommit --run'
7
+ end
8
+ end
metadata CHANGED
@@ -1,32 +1,19 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: schematist
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 1.0.0
5
5
  platform: ruby
6
6
  authors:
7
+ - Daniel Friis
7
8
  - Carmine Paolino
8
9
  bindir: bin
9
10
  cert_chain: []
10
11
  date: 1980-01-02 00:00:00.000000000 Z
11
- dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: ruby_llm-schema
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - ">="
17
- - !ruby/object:Gem::Version
18
- version: '0'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - ">="
24
- - !ruby/object:Gem::Version
25
- version: '0'
26
- description: Schematist is the next home of ruby_llm-schema. Installing schematist
27
- installs ruby_llm-schema and requiring schematist loads it. The expanded JSON Schema
28
- (draft 2020-12) toolkit lands here in a future release.
12
+ dependencies: []
13
+ description: A compact Ruby DSL for building standards-oriented JSON Schema documents
14
+ from Ruby.
29
15
  email:
16
+ - d@friis.me
30
17
  - carmine@paolino.me
31
18
  executables: []
32
19
  extensions: []
@@ -35,13 +22,29 @@ files:
35
22
  - LICENSE
36
23
  - README.md
37
24
  - lib/schematist.rb
25
+ - lib/schematist/dsl.rb
26
+ - lib/schematist/dsl/complex_types.rb
27
+ - lib/schematist/dsl/conditional_builder.rb
28
+ - lib/schematist/dsl/conditional_context.rb
29
+ - lib/schematist/dsl/conditionals.rb
30
+ - lib/schematist/dsl/primitive_types.rb
31
+ - lib/schematist/dsl/schema_builders.rb
32
+ - lib/schematist/dsl/utilities.rb
33
+ - lib/schematist/errors.rb
34
+ - lib/schematist/helpers.rb
35
+ - lib/schematist/json_output.rb
36
+ - lib/schematist/schema.rb
37
+ - lib/schematist/validator.rb
38
38
  - lib/schematist/version.rb
39
- homepage: https://github.com/crmne/ruby_llm-schema
39
+ - lib/tasks/release.rake
40
+ homepage: https://github.com/crmne/schematist#readme
40
41
  licenses:
41
42
  - MIT
42
43
  metadata:
43
- homepage_uri: https://github.com/crmne/ruby_llm-schema
44
- source_code_uri: https://github.com/crmne/ruby_llm-schema
44
+ homepage_uri: https://github.com/crmne/schematist#readme
45
+ source_code_uri: https://github.com/crmne/schematist
46
+ changelog_uri: https://github.com/crmne/schematist/releases
47
+ bug_tracker_uri: https://github.com/crmne/schematist/issues
45
48
  rubygems_mfa_required: 'true'
46
49
  rdoc_options: []
47
50
  require_paths:
@@ -59,5 +62,5 @@ required_rubygems_version: !ruby/object:Gem::Requirement
59
62
  requirements: []
60
63
  rubygems_version: 4.0.16
61
64
  specification_version: 4
62
- summary: A Ruby DSL for building JSON Schema documents.
65
+ summary: A simple Ruby DSL for creating JSON schemas.
63
66
  test_files: []