Strict Output Validator — Ruby source
Check a JSON Schema against OpenAI structured-outputs strict mode rules — open objects, missing required keys, unsupported keywords — before the API rejects it. 100% client-side.
This is the Ruby implementation — the same logic the interactive tool runs, in a shareable, citable form.
# Strict Output Validator — validate a JSON Schema against OpenAI's
# structured-outputs strict mode rules, so a schema fails HERE instead of
# at the API.
#
# Language: Ruby (3.x, stdlib JSON only)
# Port of src/lib/strictOutputValidator.ts (the canonical TypeScript
# implementation).
# Tool page: https://dev.cosmolabs.org/tools/strict-output-validator
#
# Rules (2026 OpenAI strict mode):
# R1 root must be type "object" (validate_strict_root)
# R2 every object node needs additionalProperties: false
# R3 every key in properties must be in required
# R4 required must not name keys absent from properties
# R5 only supported type values / keywords may appear
require 'json'
module StrictOutputValidator
# Types strict mode supports.
SUPPORTED_TYPES = %w[object array string number integer boolean].freeze
# Keywords strict mode understands per-node. Everything else is flagged.
SUPPORTED_KEYWORDS = %w[
type description title properties required additionalProperties items
enum const anyOf allOf $ref $defs definitions format nullable default
].freeze
Issue = Struct.new(:path, :rule, :message, keyword_init: true)
Report = Struct.new(
:ok, :issues, :objects, :properties, :enums, keyword_init: true
)
module_function
# validateStrictSchema: walk the parsed schema (a Hash from JSON.parse)
# and collect rule issues.
def validate_strict_schema(schema)
issues = []
counts = { objects: 0, properties: 0, enums: 0 }
unless schema.is_a?(Hash)
issues << Issue.new(
path: '$', rule: 'invalid-schema', message: 'Schema must be a JSON object.'
)
return Report.new(ok: false, issues: issues, objects: 0, properties: 0, enums: 0)
end
walk = lambda do |node, path|
# R5 keywords
node.each_key do |key|
next if SUPPORTED_KEYWORDS.include?(key)
issues << Issue.new(
path: path, rule: 'unsupported-keyword',
message: "\"#{key}\" is not supported in strict mode — remove it or express " \
'the constraint another way.'
)
end
# R5 types ('null' only inside a type array).
type = node['type']
case type
when String
unless SUPPORTED_TYPES.include?(type)
issues << Issue.new(
path: path, rule: 'unsupported-type',
message: "type \"#{type}\" is not supported — strict mode allows object, " \
'array, string, number, integer, boolean (null only inside a type array).'
)
end
when Array
type.each do |t|
ok = t.is_a?(String) && (SUPPORTED_TYPES.include?(t) || t == 'null')
next if ok
issues << Issue.new(
path: path, rule: 'unsupported-type',
message: 'a type-array entry is not supported — strict mode allows object, ' \
'array, string, number, integer, boolean, null.'
)
end
end
# allOf is accepted only as a single-element wrapper.
all_of = node['allOf']
if all_of.is_a?(Array) && all_of.length != 1
issues << Issue.new(
path: path, rule: 'unsupported-keyword',
message: 'allOf is supported only with exactly one subschema (use anyOf for unions).'
)
end
# Object node: R2, R3, R4.
is_object_node = type == 'object' || node.key?('properties') || node.key?('required')
if is_object_node
counts[:objects] += 1
ap = node['additionalProperties']
ap_false = ap.equal?(false)
unless ap_false
issues << Issue.new(
path: path, rule: 'missing-additional-properties',
message: 'Object needs "additionalProperties": false — strict mode rejects ' \
'open objects.'
)
end
props = node['properties'].is_a?(Hash) ? node['properties'] : nil
required = node['required'].is_a?(Array) ? node['required'] : nil
req_keys = required.to_a.grep(String)
if props
counts[:properties] += props.length
props.each_key do |key|
next if req_keys.include?(key)
issues << Issue.new(
path: "#{path}.required", rule: 'property-not-required',
message: "\"#{key}\" is defined in properties but missing from required — " \
'strict mode requires every property.'
)
end
req_keys.each do |key|
next if props.key?(key)
issues << Issue.new(
path: "#{path}.required", rule: 'required-not-property',
message: "\"#{key}\" is required but has no definition in properties."
)
end
props.each do |key, sub|
walk.call(sub, "#{path}.properties.#{key}") if sub.is_a?(Hash)
end
else
req_keys.each do |key|
issues << Issue.new(
path: "#{path}.required", rule: 'required-not-property',
message: "\"#{key}\" is required but has no definition in properties."
)
end
end
end
walk.call(node['items'], "#{path}.items") if node['items'].is_a?(Hash)
counts[:enums] += 1 if node['enum'].is_a?(Array)
%w[anyOf oneOf allOf].each do |list_key|
list = node[list_key]
next unless list.is_a?(Array)
if list_key == 'oneOf'
issues << Issue.new(
path: "#{path}.#{list_key}", rule: 'unsupported-keyword',
message: 'oneOf is not supported — strict mode unions are expressed with anyOf.'
)
end
list.each_with_index do |sub, i|
walk.call(sub, "#{path}.#{list_key}[#{i}]") if sub.is_a?(Hash)
end
end
%w[$defs definitions].each do |defs_key|
defs = node[defs_key]
next unless defs.is_a?(Hash)
defs.each do |name, sub|
walk.call(sub, "#{path}.#{defs_key}.#{name}") if sub.is_a?(Hash)
end
end
end
walk.call(schema, '$')
Report.new(
ok: issues.empty?, issues: issues,
objects: counts[:objects], properties: counts[:properties], enums: counts[:enums]
)
end
# The whole-report entry point: parse + validate + R1 (root must be
# type "object"). `input` is a JSON string.
def validate_strict_root(input)
schema = begin
input.is_a?(String) ? JSON.parse(input) : input
rescue JSON::ParserError => e
return Report.new(
ok: false,
issues: [Issue.new(path: '$', rule: 'invalid-schema',
message: "Not valid JSON: #{e.message}")],
objects: 0, properties: 0, enums: 0
)
end
report = validate_strict_schema(schema)
if schema.is_a?(Hash) && schema['type'] != 'object'
report.issues.unshift(
Issue.new(
path: '$', rule: 'root-not-object',
message: 'The root schema must be type "object" — strict mode cannot return ' \
'a bare scalar or array.'
)
)
report.ok = false
end
report
end
end
Also available in 13 other languages
Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →