diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..63534d6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,29 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + ruby-version: ['3.1', '3.2', '3.3', '3.4'] + + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby ${{ matrix.ruby-version }} + uses: ruby/setup-ruby@v1 + with: + ruby-version: ${{ matrix.ruby-version }} + bundler-cache: true + + - name: Run Standard (linting) + run: bundle exec rake standard + + - name: Run RSpec (tests) + run: bundle exec rake spec diff --git a/Rakefile b/Rakefile index 26129b3..14ee450 100644 --- a/Rakefile +++ b/Rakefile @@ -3,3 +3,7 @@ require "bundler/gem_tasks" require "rspec/core/rake_task" require "standard/rake" + +RSpec::Core::RakeTask.new(:spec) + +task default: %i[standard spec] diff --git a/lib/ruby_llm/schema.rb b/lib/ruby_llm/schema.rb index bf80395..1a380ce 100644 --- a/lib/ruby_llm/schema.rb +++ b/lib/ruby_llm/schema.rb @@ -7,13 +7,12 @@ require_relative "schema/dsl" require_relative "schema/json_output" require "json" -require "set" module RubyLLM class Schema extend DSL include JsonOutput - + PRIMITIVE_TYPES = %i[string number integer boolean null].freeze class << self diff --git a/lib/ruby_llm/schema/dsl.rb b/lib/ruby_llm/schema/dsl.rb index baedc46..475e3b5 100644 --- a/lib/ruby_llm/schema/dsl.rb +++ b/lib/ruby_llm/schema/dsl.rb @@ -13,7 +13,7 @@ def string(name = nil, enum: nil, description: nil, required: true, min_length: pattern: pattern, format: format }.compact - + add_property(name, build_property_schema(:string, **options), required: required) end @@ -24,7 +24,7 @@ def number(name = nil, description: nil, required: true, minimum: nil, maximum: maximum: maximum, multipleOf: multiple_of }.compact - + add_property(name, build_property_schema(:number, **options), required: required) end @@ -161,7 +161,7 @@ def determine_array_items(of, &) def collect_property_schemas_from_block(&block) schemas = [] schema_builder = self # Capture the current context that has build_property_schema - + context = Object.new context.define_singleton_method(:string) { |name = nil, **options| schemas << schema_builder.build_property_schema(:string, **options) } context.define_singleton_method(:number) { |name = nil, **options| schemas << schema_builder.build_property_schema(:number, **options) } @@ -170,7 +170,7 @@ def collect_property_schemas_from_block(&block) context.define_singleton_method(:null) { |name = nil, **options| schemas << schema_builder.build_property_schema(:null, **options) } context.define_singleton_method(:object) { |name = nil, **options, &blk| schemas << schema_builder.build_property_schema(:object, **options, &blk) } context.define_singleton_method(:any_of) { |name = nil, **options, &blk| schemas << schema_builder.build_property_schema(:any_of, **options, &blk) } - + context.instance_eval(&block) schemas end diff --git a/lib/ruby_llm/schema/json_output.rb b/lib/ruby_llm/schema/json_output.rb index 8035b9f..7d66e8d 100644 --- a/lib/ruby_llm/schema/json_output.rb +++ b/lib/ruby_llm/schema/json_output.rb @@ -5,7 +5,7 @@ class Schema module JsonOutput def to_json_schema validate! # Validate schema before generating JSON - + { name: @name, description: @description || self.class.description, diff --git a/lib/ruby_llm/schema/validator.rb b/lib/ruby_llm/schema/validator.rb index eda7774..6c46a71 100644 --- a/lib/ruby_llm/schema/validator.rb +++ b/lib/ruby_llm/schema/validator.rb @@ -32,7 +32,7 @@ def validate_circular_references! # Initialize all nodes as WHITE (no mark) marks = Hash.new { WHITE } - + # Visit each unmarked node definitions.each_key do |node| visit(node, definitions, marks) if marks[node] == WHITE @@ -43,7 +43,7 @@ def validate_circular_references! def visit(node, definitions, marks) # If node has a permanent mark, return return if marks[node] == BLACK - + # If node has a temporary mark, we found a cycle if marks[node] == GRAY raise ValidationError, "Circular reference detected involving '#{node}'" @@ -51,7 +51,7 @@ def visit(node, definitions, marks) # Mark node with temporary mark marks[node] = GRAY - + # Visit all adjacent nodes (dependencies) definition = definitions[node] if definition && definition[:properties] @@ -62,14 +62,14 @@ def visit(node, definitions, marks) end end end - + # Mark node with permanent mark marks[node] = BLACK end def extract_references(property) references = [] - + case property when Hash if property["$ref"] diff --git a/lib/tasks/release.rake b/lib/tasks/release.rake index 79df057..347f55c 100644 --- a/lib/tasks/release.rake +++ b/lib/tasks/release.rake @@ -2,11 +2,11 @@ namespace :release do desc "Release a new version of the gem" task :version, [:message] do |t, args| # Load the current version from version.rb - require_relative '../../lib/ruby_llm/schema/version' + require_relative "../../lib/ruby_llm/schema/version" version = RubyLlm::Schema::VERSION - + puts "Releasing version #{version}..." - + # Create git tag with optional message # rake release:version["Fix critical bug in schema validation"] if args[:message] @@ -16,7 +16,7 @@ namespace :release do system "git tag v#{version}" puts "Created lightweight tag v#{version}" end - + system "git push origin main" system "git push origin v#{version}" diff --git a/spec/ruby_llm/schema_class_inheritance_spec.rb b/spec/ruby_llm/schema_class_inheritance_spec.rb index f1ed14f..3fbdaea 100644 --- a/spec/ruby_llm/schema_class_inheritance_spec.rb +++ b/spec/ruby_llm/schema_class_inheritance_spec.rb @@ -8,7 +8,7 @@ describe "name" do it "uses class name when provided (via constant)" do - TestNamedSchema = Class.new(described_class) + stub_const("TestNamedSchema", Class.new(described_class)) instance = TestNamedSchema.new expect(instance.to_json_schema[:name]).to eq("TestNamedSchema") end @@ -79,13 +79,13 @@ string :name, description: "Name field" integer :count boolean :active, required: false - + object :config do string :setting end - + array :tags, of: :string - + any_of :status do string null @@ -121,7 +121,7 @@ description: "Test description", schema: hash_including( type: "object", - properties: { title: { type: "string" } }, + properties: {title: {type: "string"}}, required: [:title], additionalProperties: false, strict: true @@ -141,8 +141,8 @@ expect(json_output).to include( name: "ConfiguredSchema", - description: "Instance description", + description: "Instance description" ) end end -end \ No newline at end of file +end diff --git a/spec/ruby_llm/schema_factory_spec.rb b/spec/ruby_llm/schema_factory_spec.rb index 9bccc00..04cda40 100644 --- a/spec/ruby_llm/schema_factory_spec.rb +++ b/spec/ruby_llm/schema_factory_spec.rb @@ -6,9 +6,9 @@ describe "configuration options" do describe "name" do it "uses class name when provided (via constant assignment)" do - NamedFactorySchema = described_class.create do + stub_const("NamedFactorySchema", described_class.create do string :title - end + end) instance = NamedFactorySchema.new expect(instance.to_json_schema[:name]).to eq("NamedFactorySchema") @@ -96,13 +96,13 @@ string :name, description: "Name field" integer :count boolean :active, required: false - + object :config do string :setting end - + array :tags, of: :string - + any_of :status do string null @@ -138,7 +138,7 @@ description: "Factory test description", schema: hash_including( type: "object", - properties: { title: { type: "string" } }, + properties: {title: {type: "string"}}, required: [:title], additionalProperties: false, strict: true @@ -158,8 +158,8 @@ expect(json_output).to include( name: "FactoryConfiguredSchema", - description: "Instance description", + description: "Instance description" ) end end -end \ No newline at end of file +end diff --git a/spec/ruby_llm/schema_helpers_spec.rb b/spec/ruby_llm/schema_helpers_spec.rb index bf8d394..5f97eba 100644 --- a/spec/ruby_llm/schema_helpers_spec.rb +++ b/spec/ruby_llm/schema_helpers_spec.rb @@ -91,13 +91,13 @@ string :name, description: "Name field" integer :count boolean :active, required: false - + object :config do string :setting end - + array :tags, of: :string - + any_of :status do string null @@ -131,7 +131,7 @@ description: "Helper test description", schema: hash_including( type: "object", - properties: { title: { type: "string" } }, + properties: {title: {type: "string"}}, required: [:title], additionalProperties: false, strict: true @@ -139,4 +139,4 @@ ) end end -end \ No newline at end of file +end diff --git a/spec/ruby_llm/schema_spec.rb b/spec/ruby_llm/schema_spec.rb index 2014b11..1616f03 100644 --- a/spec/ruby_llm/schema_spec.rb +++ b/spec/ruby_llm/schema_spec.rb @@ -11,7 +11,7 @@ it "supports string type with enum and description" do schema_class.string :status, enum: %w[active inactive], description: "Status field" - + properties = schema_class.properties expect(properties[:status]).to eq({ type: "string", @@ -22,7 +22,7 @@ it "supports string type with additional properties" do schema_class.string :email, format: "email", min_length: 5, max_length: 100, pattern: "\\S+@\\S+", description: "Email field" - + properties = schema_class.properties expect(properties[:email]).to eq({ type: "string", @@ -36,7 +36,7 @@ it "supports number type with constraints" do schema_class.number :price, minimum: 0, maximum: 1000, multiple_of: 0.01, description: "Price field" - + properties = schema_class.properties expect(properties[:price]).to eq({ type: "number", @@ -49,28 +49,28 @@ it "supports number type with description" do schema_class.number :price, description: "Price field" - + properties = schema_class.properties expect(properties[:price]).to eq({type: "number", description: "Price field"}) end it "supports integer type with description" do schema_class.integer :count, description: "Count value" - + properties = schema_class.properties expect(properties[:count]).to eq({type: "integer", description: "Count value"}) end it "supports boolean type with description" do schema_class.boolean :enabled, description: "Enabled field" - + properties = schema_class.properties expect(properties[:enabled]).to eq({type: "boolean", description: "Enabled field"}) end it "supports null type with description" do schema_class.null :placeholder, description: "Null field" - + properties = schema_class.properties expect(properties[:placeholder]).to eq({type: "null", description: "Null field"}) end @@ -97,7 +97,7 @@ schema_class.array :booleans, of: :boolean properties = schema_class.properties - + expect(properties[:strings]).to eq({type: "array", items: {type: "string"}, description: "String array"}) expect(properties[:numbers]).to eq({type: "array", items: {type: "number"}}) expect(properties[:integers]).to eq({type: "array", items: {type: "integer"}}) @@ -106,7 +106,7 @@ it "supports arrays with constraints" do schema_class.array :strings, of: :string, min_items: 1, max_items: 10, description: "String array" - + properties = schema_class.properties expect(properties[:strings]).to eq({type: "array", items: {type: "string"}, minItems: 1, maxItems: 10, description: "String array"}) end @@ -136,7 +136,7 @@ string :name number :price end - + schema_class.array :products, of: :product properties = schema_class.properties @@ -193,7 +193,7 @@ instance = schema_class.new properties = instance.to_json_schema[:schema][:properties] - + level3 = properties[:level1][:properties][:level2][:properties][:level3] expect(level3[:properties][:deep_value]).to eq({type: "string"}) end @@ -210,7 +210,7 @@ properties = schema_class.properties any_of_schemas = properties[:flexible_field][:anyOf] - + expect(any_of_schemas).to include( {type: "string", enum: %w[option1 option2]}, {type: "integer"}, @@ -269,7 +269,7 @@ it "handles naming correctly" do # Named class - TestSchemaClass = Class.new(described_class) + stub_const("TestSchemaClass", Class.new(described_class)) named_instance = TestSchemaClass.new expect(named_instance.to_json_schema[:name]).to eq("TestSchemaClass") @@ -288,7 +288,7 @@ it "supports method delegation for schema methods" do instance = schema_class.new - + expect(instance).to respond_to(:string, :number, :integer, :boolean, :array, :object, :any_of, :null) expect(instance).not_to respond_to(:unknown_method) end @@ -296,7 +296,7 @@ it "produces correctly structured JSON schema and JSON output" do schema_class.string :name schema_class.integer :age, required: false - + instance = schema_class.new("TestSchema") json_output = instance.to_json_schema @@ -349,7 +349,7 @@ expect { schema_class.array :items, of: :undefined_reference }.not_to raise_error - + properties = schema_class.properties expect(properties[:items][:items]).to eq({"$ref" => "#/$defs/undefined_reference"}) end @@ -372,7 +372,7 @@ expect(schema_class.valid?).to be false expect { schema_class.validate! }.to raise_error( - RubyLLM::Schema::ValidationError, + RubyLLM::Schema::ValidationError, /Circular reference detected involving 'user'/ ) end @@ -423,7 +423,7 @@ empty_schema = Class.new(described_class) empty_instance = empty_schema.new("EmptySchema") empty_output = empty_instance.to_json_schema - + expect(empty_output[:schema][:properties]).to eq({}) expect(empty_output[:schema][:required]).to eq([]) @@ -435,7 +435,7 @@ optional_instance = optional_schema.new optional_output = optional_instance.to_json_schema - + expect(optional_output[:schema][:required]).to eq([]) expect(optional_output[:schema][:properties].keys).to contain_exactly(:optional1, :optional2) end @@ -443,15 +443,15 @@ it "handles complex nested structures with all features" do complex_schema = Class.new(described_class) do string :id, description: "Unique identifier" - + object :metadata do string :created_by integer :version boolean :published, required: false end - + array :tags, of: :string, description: "Resource tags" - + array :items do object do string :name @@ -463,17 +463,17 @@ end end end - + any_of :status do string enum: %w[draft published] null end - + define :author do string :name string :email end - + array :authors, of: :author end @@ -486,7 +486,7 @@ ) expect(json_output[:schema]["$defs"][:author]).to be_a(Hash) expect(json_output[:schema][:required]).to include(:id, :metadata, :tags, :items, :status, :authors) - + # Verify descriptions are preserved expect(json_output[:schema][:properties][:id][:description]).to eq("Unique identifier") expect(json_output[:schema][:properties][:tags][:description]).to eq("Resource tags")