synthra 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 +7 -0
- data/.rspec +4 -0
- data/.rubocop.yml +40 -0
- data/.yardopts +18 -0
- data/ACCESS_DOCS.md +116 -0
- data/ADVANCED_FEATURES_SUMMARY.md +245 -0
- data/CHANGELOG.md +498 -0
- data/CODE_OF_CONDUCT.md +122 -0
- data/CONTRIBUTING.md +307 -0
- data/DOCUMENTATION.md +204 -0
- data/DOCUMENTATION_GUIDE.md +194 -0
- data/LICENSE +22 -0
- data/README.md +1458 -0
- data/Rakefile +128 -0
- data/benchmark/README.md +591 -0
- data/benchmark/batch_generation.rb +63 -0
- data/benchmark/engine_comparison.rb +295 -0
- data/benchmark/single_record.rb +108 -0
- data/benchmark/streaming.rb +74 -0
- data/docs/ACTIVERECORD_INFERENCE.md +524 -0
- data/docs/API_SERVER.md +278 -0
- data/docs/CONFIG_FILE.md +315 -0
- data/docs/DATA_CONTRACTS.md +312 -0
- data/docs/ENHANCED_REPL.md +304 -0
- data/docs/FACTORY_BOT.md +271 -0
- data/docs/GITHUB_ACTION.md +420 -0
- data/docs/GRAPHQL_EXPORT.md +303 -0
- data/docs/GRAPHQL_FEDERATION.md +651 -0
- data/docs/HOW_TO_GENERATE_DOCS.md +252 -0
- data/docs/LSP.md +189 -0
- data/docs/MIGRATION_GENERATOR.md +547 -0
- data/docs/MOCK_SERVER.md +280 -0
- data/docs/NATIVE_ENGINE.md +298 -0
- data/docs/OPENAPI_EXPORT.md +252 -0
- data/docs/PERFORMANCE_MODE.md +228 -0
- data/docs/PERSONAS.md +547 -0
- data/docs/PROPERTY_TESTING.md +284 -0
- data/docs/PROTOBUF_EXPORT.md +276 -0
- data/docs/QUALITY_METRICS.md +555 -0
- data/docs/QUICK_REFERENCE.md +88 -0
- data/docs/RAILS_ENGINE.md +404 -0
- data/docs/RAILS_INTEGRATION.md +454 -0
- data/docs/README.md +175 -0
- data/docs/REPL_QUICK_REFERENCE.md +149 -0
- data/docs/RUST_INTEGRATION_GUIDE.md +1376 -0
- data/docs/SCENARIOS.md +576 -0
- data/docs/SECURITY_TESTING.md +318 -0
- data/docs/SNAPSHOT_TESTING.md +308 -0
- data/docs/STRING_TEXT_TYPES_REFERENCE.md +512 -0
- data/docs/TERRAFORM_EXPORT.md +447 -0
- data/docs/TIME_TRAVEL.md +507 -0
- data/docs/USAGE_GUIDE.md +980 -0
- data/docs/WEBHOOK_SIMULATOR.md +579 -0
- data/docs/advanced_features.md +183 -0
- data/docs/api_reference.md +153 -0
- data/docs/behaviors.md +138 -0
- data/docs/best_practices.md +191 -0
- data/docs/dsl_reference.md +172 -0
- data/docs/examples.md +162 -0
- data/docs/generation_modes.md +125 -0
- data/docs/getting_started.md +92 -0
- data/docs/index.html +177 -0
- data/docs/strategic/CLOUD_REGISTRY_ARCHITECTURE.md +268 -0
- data/docs/strategic/SIMD_OPTIMIZATION_STRATEGY.md +153 -0
- data/docs/troubleshooting.md +215 -0
- data/docs/type_reference.md +224 -0
- data/examples/hostile_mode_demo.rb +64 -0
- data/exe/synthra +6 -0
- data/ext/synthra_native/Cargo.lock +972 -0
- data/ext/synthra_native/Cargo.toml +46 -0
- data/ext/synthra_native/extconf.rb +41 -0
- data/ext/synthra_native/src/generator.rs +397 -0
- data/ext/synthra_native/src/lib.rs +229 -0
- data/ext/synthra_native/src/types.rs +1485 -0
- data/lib/generators/synthra/install_generator.rb +86 -0
- data/lib/generators/synthra/templates/api_response.dsl +15 -0
- data/lib/generators/synthra/templates/fake_data.rake +116 -0
- data/lib/generators/synthra/templates/synthra.yml +31 -0
- data/lib/generators/synthra/templates/synthra_support.rb +19 -0
- data/lib/generators/synthra/templates/user.dsl +11 -0
- data/lib/synthra/activerecord_inference.rb +394 -0
- data/lib/synthra/api.rb +381 -0
- data/lib/synthra/api_server.rb +718 -0
- data/lib/synthra/behaviors/applicator.rb +165 -0
- data/lib/synthra/behaviors/base.rb +155 -0
- data/lib/synthra/behaviors/close_connection.rb +60 -0
- data/lib/synthra/behaviors/deprecated.rb +100 -0
- data/lib/synthra/behaviors/failure.rb +69 -0
- data/lib/synthra/behaviors/latency.rb +103 -0
- data/lib/synthra/behaviors/partial_data.rb +83 -0
- data/lib/synthra/behaviors/randomize_order.rb +74 -0
- data/lib/synthra/behaviors/registry.rb +238 -0
- data/lib/synthra/behaviors/simulate_error.rb +79 -0
- data/lib/synthra/cli/commands/base.rb +105 -0
- data/lib/synthra/cli/commands/diff.rb +227 -0
- data/lib/synthra/cli/commands/docs.rb +67 -0
- data/lib/synthra/cli/commands/export.rb +150 -0
- data/lib/synthra/cli/commands/generate.rb +72 -0
- data/lib/synthra/cli/commands/import.rb +80 -0
- data/lib/synthra/cli/commands/lint.rb +53 -0
- data/lib/synthra/cli/commands/live.rb +57 -0
- data/lib/synthra/cli/commands/seed.rb +99 -0
- data/lib/synthra/cli/commands/validate.rb +28 -0
- data/lib/synthra/cli.rb +2471 -0
- data/lib/synthra/config_file.rb +129 -0
- data/lib/synthra/configuration.rb +281 -0
- data/lib/synthra/contracts_registry.rb +408 -0
- data/lib/synthra/database_seeder.rb +268 -0
- data/lib/synthra/deterministic_ids.rb +218 -0
- data/lib/synthra/documentation_generator.rb +414 -0
- data/lib/synthra/engine.rb +251 -0
- data/lib/synthra/errors.rb +1169 -0
- data/lib/synthra/export/base.rb +85 -0
- data/lib/synthra/export/csv.rb +101 -0
- data/lib/synthra/export/graphql.rb +266 -0
- data/lib/synthra/export/graphql_federation.rb +377 -0
- data/lib/synthra/export/graphviz.rb +258 -0
- data/lib/synthra/export/javascript.rb +327 -0
- data/lib/synthra/export/json_data.rb +61 -0
- data/lib/synthra/export/json_schema.rb +290 -0
- data/lib/synthra/export/openapi.rb +514 -0
- data/lib/synthra/export/protobuf.rb +483 -0
- data/lib/synthra/export/python.rb +560 -0
- data/lib/synthra/export/sql.rb +381 -0
- data/lib/synthra/export/sql_insert.rb +152 -0
- data/lib/synthra/export/terraform.rb +501 -0
- data/lib/synthra/export/type_mapping.rb +389 -0
- data/lib/synthra/export/typescript.rb +300 -0
- data/lib/synthra/export/xml_data.rb +104 -0
- data/lib/synthra/export/yaml_data.rb +56 -0
- data/lib/synthra/export.rb +404 -0
- data/lib/synthra/factory_bot_integration.rb +157 -0
- data/lib/synthra/field.rb +440 -0
- data/lib/synthra/functions/registry.rb +104 -0
- data/lib/synthra/generator/context.rb +372 -0
- data/lib/synthra/generator/engine.rb +336 -0
- data/lib/synthra/generator/faker_adapter.rb +425 -0
- data/lib/synthra/generator/modes.rb +444 -0
- data/lib/synthra/generator/resolver.rb +256 -0
- data/lib/synthra/generator/rng.rb +273 -0
- data/lib/synthra/generator/streamer.rb +63 -0
- data/lib/synthra/generator/uniqueness.rb +118 -0
- data/lib/synthra/initializer.rb +97 -0
- data/lib/synthra/limits.rb +279 -0
- data/lib/synthra/live_preview.rb +518 -0
- data/lib/synthra/loader_config.rb +78 -0
- data/lib/synthra/lsp/server.rb +689 -0
- data/lib/synthra/migration_generator.rb +301 -0
- data/lib/synthra/mixin.rb +174 -0
- data/lib/synthra/mock_server.rb +458 -0
- data/lib/synthra/native_engine.rb +304 -0
- data/lib/synthra/openapi_importer.rb +228 -0
- data/lib/synthra/output/json_formatter.rb +40 -0
- data/lib/synthra/output/ndjson_formatter.rb +55 -0
- data/lib/synthra/parser/ast.rb +1287 -0
- data/lib/synthra/parser/lexer.rb +1152 -0
- data/lib/synthra/parser/parser.rb +1664 -0
- data/lib/synthra/parser/tokens.rb +461 -0
- data/lib/synthra/performance_mode.rb +364 -0
- data/lib/synthra/personas.rb +397 -0
- data/lib/synthra/property_testing.rb +172 -0
- data/lib/synthra/quality_metrics.rb +405 -0
- data/lib/synthra/rails_test_helper.rb +248 -0
- data/lib/synthra/registry.rb +533 -0
- data/lib/synthra/relationships.rb +193 -0
- data/lib/synthra/repl/enhanced_repl.rb +607 -0
- data/lib/synthra/repl/formatter.rb +191 -0
- data/lib/synthra/scenarios.rb +423 -0
- data/lib/synthra/schema.rb +605 -0
- data/lib/synthra/schema_inheritance.rb +104 -0
- data/lib/synthra/schema_versioning.rb +212 -0
- data/lib/synthra/snapshot_testing.rb +199 -0
- data/lib/synthra/time_travel.rb +338 -0
- data/lib/synthra/type_definitions.rb +274 -0
- data/lib/synthra/types/address_location/addresses.rb +125 -0
- data/lib/synthra/types/address_location/airports.rb +200 -0
- data/lib/synthra/types/address_location/banks_hospitals.rb +207 -0
- data/lib/synthra/types/address_location/locations.rb +406 -0
- data/lib/synthra/types/base.rb +48 -0
- data/lib/synthra/types/commerce_products/commerce.rb +119 -0
- data/lib/synthra/types/commerce_products/companies.rb +71 -0
- data/lib/synthra/types/commerce_products/construction.rb +126 -0
- data/lib/synthra/types/commerce_products/products.rb +205 -0
- data/lib/synthra/types/core/collections.rb +513 -0
- data/lib/synthra/types/core/defaults.rb +76 -0
- data/lib/synthra/types/core/enums.rb +102 -0
- data/lib/synthra/types/core/identifiers.rb +445 -0
- data/lib/synthra/types/core/primitives.rb +586 -0
- data/lib/synthra/types/core/references.rb +466 -0
- data/lib/synthra/types/core/sequences.rb +304 -0
- data/lib/synthra/types/crypto/crypto.rb +162 -0
- data/lib/synthra/types/date_time/dates.rb +387 -0
- data/lib/synthra/types/finance_banking/banking.rb +424 -0
- data/lib/synthra/types/finance_banking/credit_cards.rb +24 -0
- data/lib/synthra/types/finance_banking/identifiers.rb +93 -0
- data/lib/synthra/types/formula.rb +184 -0
- data/lib/synthra/types/health_medical/medical.rb +150 -0
- data/lib/synthra/types/hostile_payloads.rb +122 -0
- data/lib/synthra/types/json_array.rb +51 -0
- data/lib/synthra/types/media_entertainment/media.rb +62 -0
- data/lib/synthra/types/naughty_string.rb +30 -0
- data/lib/synthra/types/personal_names/chinese.rb +41 -0
- data/lib/synthra/types/personal_names/identifiers.rb +187 -0
- data/lib/synthra/types/personal_names/names.rb +246 -0
- data/lib/synthra/types/personal_names/national_id.rb +89 -0
- data/lib/synthra/types/personal_names/titles_suffixes.rb +41 -0
- data/lib/synthra/types/regex.rb +248 -0
- data/lib/synthra/types/registry.rb +182 -0
- data/lib/synthra/types/repeating_element.rb +50 -0
- data/lib/synthra/types/scenario.rb +29 -0
- data/lib/synthra/types/technology_internet/apps.rb +67 -0
- data/lib/synthra/types/technology_internet/communication.rb +162 -0
- data/lib/synthra/types/technology_internet/devices.rb +80 -0
- data/lib/synthra/types/technology_internet/formats.rb +139 -0
- data/lib/synthra/types/technology_internet/networking.rb +143 -0
- data/lib/synthra/types/template.rb +92 -0
- data/lib/synthra/types/text_content/business.rb +128 -0
- data/lib/synthra/types/text_content/colors.rb +70 -0
- data/lib/synthra/types/text_content/misc.rb +237 -0
- data/lib/synthra/types/text_content/security.rb +130 -0
- data/lib/synthra/types/text_content/text_generation.rb +532 -0
- data/lib/synthra/types/travel/travel.rb +150 -0
- data/lib/synthra/utils/string_distance.rb +133 -0
- data/lib/synthra/validator/dsl_validator.rb +339 -0
- data/lib/synthra/validator/path_validator.rb +615 -0
- data/lib/synthra/version.rb +35 -0
- data/lib/synthra/webhook_simulator.rb +341 -0
- data/lib/synthra.rb +259 -0
- data/schemas/address.dsl +16 -0
- data/schemas/api_response.dsl +8 -0
- data/schemas/error_payload.dsl +9 -0
- data/schemas/order.dsl +10 -0
- data/schemas/order_item.dsl +8 -0
- data/schemas/payment.dsl +11 -0
- data/schemas/social_post.dsl +13 -0
- data/schemas/user.dsl +10 -0
- data/scripts/batch_fix_all.rb +96 -0
- data/scripts/delete_old_files.rb +32 -0
- data/scripts/fix_all_grouped_files.rb +137 -0
- data/scripts/fix_all_indentation.rb +48 -0
- data/scripts/fix_all_syntax.rb +124 -0
- data/scripts/fix_grouped_files.rb +184 -0
- data/scripts/fix_syntax_errors.rb +108 -0
- data/scripts/group_domain_types.rb +112 -0
- data/scripts/group_domain_types_fixed.rb +150 -0
- data/scripts/merge_domains_to_one_file.rb +68 -0
- data/scripts/move_existing_types.rb +130 -0
- data/scripts/split_grouped_types.rb +142 -0
- data/tech_docs/README.md +134 -0
- data/tech_docs/advanced/streaming.md +405 -0
- data/tech_docs/advanced/thread_safety.md +253 -0
- data/tech_docs/api/overview.md +485 -0
- data/tech_docs/appendices/type_chart.md +193 -0
- data/tech_docs/basic_concepts.md +383 -0
- data/tech_docs/behaviors/overview.md +346 -0
- data/tech_docs/dsl/complex_types.md +790 -0
- data/tech_docs/dsl/core_types.md +464 -0
- data/tech_docs/dsl/datetime_types.md +325 -0
- data/tech_docs/dsl/field_modifiers.md +414 -0
- data/tech_docs/dsl/grammar.md +431 -0
- data/tech_docs/dsl/schema_definition.md +399 -0
- data/tech_docs/export/README.md +276 -0
- data/tech_docs/installation.md +273 -0
- data/tech_docs/integration/ci_cd.md +707 -0
- data/tech_docs/integration/ci_cd_guide.md +579 -0
- data/tech_docs/integration/factory_bot.md +485 -0
- data/tech_docs/integration/rails.md +630 -0
- data/tech_docs/integration/rspec.md +449 -0
- data/tech_docs/modes/overview.md +350 -0
- data/tech_docs/performance/NATIVE_RUST_EXTENSION.md +1270 -0
- data/tech_docs/performance/OPTIMIZATION_GUIDE.md +901 -0
- data/tech_docs/quick_start.md +256 -0
- data/tech_docs/templates/README.md +805 -0
- data/tech_docs/tutorials/advanced.md +371 -0
- data/tech_docs/tutorials/getting_started.md +189 -0
- data/tech_docs/tutorials/intermediate.md +231 -0
- data/tech_docs/tutorials/template_gallery.md +569 -0
- data/tech_example/01_basic_usage.rb +238 -0
- data/tech_example/02_types_demo.rb +336 -0
- data/tech_example/04_cli_usage.md +429 -0
- data/tech_example/05_database_seeding.rb +283 -0
- data/tech_example/07_rspec_integration.rb +359 -0
- data/tech_example/10_custom_types.rb +387 -0
- data/tech_example/12_behaviors.rb +372 -0
- data/tech_example/13_twitter_dm_example.rb +120 -0
- data/tech_example/14_exact_json_structure.rb +253 -0
- data/tech_example/16_lsp_server.rb +56 -0
- data/tech_example/17_property_testing.rb +92 -0
- data/tech_example/18_enhanced_repl.rb +120 -0
- data/tech_example/19_security_fuzzing.rb +145 -0
- data/tech_example/NEW_FEATURES.md +169 -0
- data/tech_example/README.md +118 -0
- data/tech_example/schemas/api_response.dsl +123 -0
- data/tech_example/schemas/ecommerce.dsl +121 -0
- data/tech_example/schemas/twitter_dm.dsl +109 -0
- data/tech_example/schemas/user.dsl +36 -0
- data/vscode-extension/README.md +246 -0
- data/vscode-extension/language-configuration.json +31 -0
- data/vscode-extension/package.json +55 -0
- data/vscode-extension/snippets/fakedatadsl.json +198 -0
- data/vscode-extension/syntaxes/fakedatadsl.tmLanguage.json +128 -0
- metadata +478 -0
data/lib/synthra/cli.rb
ADDED
|
@@ -0,0 +1,2471 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# =============================================================================
|
|
4
|
+
|
|
5
|
+
# Synthra Command Line Interface
|
|
6
|
+
# =============================================================================
|
|
7
|
+
#
|
|
8
|
+
# Provides a command-line interface for validating schemas and generating
|
|
9
|
+
# fake data. The CLI supports multiple output formats and generation modes.
|
|
10
|
+
#
|
|
11
|
+
# @example Validate a schema
|
|
12
|
+
# $ synthra validate schemas/user.dsl
|
|
13
|
+
# Schema valid: schemas/user.dsl
|
|
14
|
+
#
|
|
15
|
+
# @example Generate fake data
|
|
16
|
+
# $ synthra generate User --dir schemas --count 10 --pretty
|
|
17
|
+
#
|
|
18
|
+
# @example Generate NDJSON for streaming
|
|
19
|
+
# $ synthra generate User --dir schemas --count 1000 --ndjson
|
|
20
|
+
#
|
|
21
|
+
# =============================================================================
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
require "optparse"
|
|
25
|
+
require "fileutils"
|
|
26
|
+
|
|
27
|
+
module Synthra
|
|
28
|
+
|
|
29
|
+
# Command-line interface for Synthra
|
|
30
|
+
#
|
|
31
|
+
# The CLI provides commands for schema validation and data generation.
|
|
32
|
+
# It supports multiple output formats (JSON, NDJSON) and generation
|
|
33
|
+
# modes (random, edge, invalid, mixed).
|
|
34
|
+
#
|
|
35
|
+
# Commands:
|
|
36
|
+
# - `validate <path>`: Check if a DSL file is syntactically valid
|
|
37
|
+
# - `lint <path|dir>`: Lint schemas for path errors, missing fields, etc.
|
|
38
|
+
# - `generate <SchemaName>`: Generate fake data for a schema
|
|
39
|
+
#
|
|
40
|
+
# @example Run the CLI
|
|
41
|
+
# cli = CLI.new(ARGV)
|
|
42
|
+
# exit_code = cli.run
|
|
43
|
+
#
|
|
44
|
+
|
|
45
|
+
class CLI
|
|
46
|
+
|
|
47
|
+
# Exit code for successful execution
|
|
48
|
+
# @return [Integer] 0
|
|
49
|
+
EXIT_SUCCESS = 0
|
|
50
|
+
|
|
51
|
+
# Exit code for parse/validation errors
|
|
52
|
+
# @return [Integer] 1
|
|
53
|
+
EXIT_PARSE_ERROR = 1
|
|
54
|
+
|
|
55
|
+
# Exit code for runtime errors
|
|
56
|
+
# @return [Integer] 2
|
|
57
|
+
EXIT_RUNTIME_ERROR = 2
|
|
58
|
+
|
|
59
|
+
# Create a new CLI instance
|
|
60
|
+
#
|
|
61
|
+
# @param args [Array<String>] command-line arguments (typically ARGV)
|
|
62
|
+
#
|
|
63
|
+
# @example
|
|
64
|
+
# cli = CLI.new(["generate", "User", "--count", "10"])
|
|
65
|
+
#
|
|
66
|
+
|
|
67
|
+
def initialize(args)
|
|
68
|
+
@args = args
|
|
69
|
+
@options = {
|
|
70
|
+
count: 1, # Number of records to generate
|
|
71
|
+
mode: :random, # Generation mode
|
|
72
|
+
format: :json, # Output format
|
|
73
|
+
pretty: false, # Pretty-print JSON
|
|
74
|
+
dir: "." # Directory containing .dsl files
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Run the CLI command
|
|
79
|
+
#
|
|
80
|
+
# Parses the command and options, executes the appropriate command,
|
|
81
|
+
# and returns an exit code.
|
|
82
|
+
#
|
|
83
|
+
# @return [Integer] exit code (0 = success, 1 = parse error, 2 = runtime error)
|
|
84
|
+
#
|
|
85
|
+
# @example
|
|
86
|
+
# exit_code = cli.run
|
|
87
|
+
# exit(exit_code)
|
|
88
|
+
#
|
|
89
|
+
|
|
90
|
+
def run
|
|
91
|
+
return show_help if @args.empty?
|
|
92
|
+
|
|
93
|
+
command = @args.shift
|
|
94
|
+
case command
|
|
95
|
+
when "validate"
|
|
96
|
+
validate_command
|
|
97
|
+
when "lint"
|
|
98
|
+
lint_command
|
|
99
|
+
when "preview"
|
|
100
|
+
preview_command
|
|
101
|
+
when "generate"
|
|
102
|
+
generate_command
|
|
103
|
+
when "info"
|
|
104
|
+
info_command
|
|
105
|
+
when "diff"
|
|
106
|
+
diff_command
|
|
107
|
+
when "repl"
|
|
108
|
+
repl_command # :nocov:
|
|
109
|
+
when "export"
|
|
110
|
+
export_command
|
|
111
|
+
when "graph"
|
|
112
|
+
graph_command
|
|
113
|
+
when "watch"
|
|
114
|
+
watch_command # :nocov:
|
|
115
|
+
when "lsp"
|
|
116
|
+
lsp_command
|
|
117
|
+
when "import"
|
|
118
|
+
import_command
|
|
119
|
+
when "docs"
|
|
120
|
+
docs_command
|
|
121
|
+
when "live"
|
|
122
|
+
live_command # :nocov:
|
|
123
|
+
when "seed"
|
|
124
|
+
seed_command
|
|
125
|
+
when "server"
|
|
126
|
+
server_command # :nocov:
|
|
127
|
+
when "contracts"
|
|
128
|
+
contracts_command
|
|
129
|
+
when "perf"
|
|
130
|
+
perf_command
|
|
131
|
+
when "--help", "-h", "help"
|
|
132
|
+
show_help
|
|
133
|
+
when "--version", "-v"
|
|
134
|
+
show_version
|
|
135
|
+
else
|
|
136
|
+
$stderr.puts "Unknown command: #{command}"
|
|
137
|
+
$stderr.puts "Run 'synthra --help' for usage information."
|
|
138
|
+
EXIT_PARSE_ERROR
|
|
139
|
+
end
|
|
140
|
+
rescue Synthra::Errors::ParseError => e
|
|
141
|
+
$stderr.puts "Parse error: #{e.message}"
|
|
142
|
+
EXIT_PARSE_ERROR
|
|
143
|
+
rescue Synthra::Errors::Error => e
|
|
144
|
+
$stderr.puts "Error: #{e.message}"
|
|
145
|
+
EXIT_RUNTIME_ERROR
|
|
146
|
+
rescue Errno::ENOENT => e
|
|
147
|
+
$stderr.puts "File not found: #{e.message}"
|
|
148
|
+
EXIT_PARSE_ERROR
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
private
|
|
152
|
+
|
|
153
|
+
# Execute the validate command
|
|
154
|
+
#
|
|
155
|
+
# Loads and parses a DSL file to check for syntax errors.
|
|
156
|
+
# Prints success message or error details.
|
|
157
|
+
#
|
|
158
|
+
# @return [Integer] exit code
|
|
159
|
+
#
|
|
160
|
+
|
|
161
|
+
def validate_command
|
|
162
|
+
parse_validate_options
|
|
163
|
+
path = @args.shift
|
|
164
|
+
|
|
165
|
+
unless path
|
|
166
|
+
$stderr.puts "Usage: synthra validate <path>"
|
|
167
|
+
return EXIT_PARSE_ERROR
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
registry = Registry.new
|
|
171
|
+
registry.load_file(path)
|
|
172
|
+
puts "Schema valid: #{path}"
|
|
173
|
+
EXIT_SUCCESS
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# Execute the lint command
|
|
177
|
+
#
|
|
178
|
+
# Loads schemas and checks for semantic errors:
|
|
179
|
+
# - Invalid copy() paths
|
|
180
|
+
# - Missing map_by_field key fields
|
|
181
|
+
# - Undefined schema references
|
|
182
|
+
# - Potential typos with suggestions
|
|
183
|
+
#
|
|
184
|
+
# @return [Integer] exit code
|
|
185
|
+
#
|
|
186
|
+
|
|
187
|
+
def lint_command
|
|
188
|
+
parse_lint_options
|
|
189
|
+
path = @args.shift
|
|
190
|
+
|
|
191
|
+
unless path
|
|
192
|
+
$stderr.puts "Usage: synthra lint <path|directory>"
|
|
193
|
+
return EXIT_PARSE_ERROR
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
registry = Registry.new
|
|
197
|
+
|
|
198
|
+
# Load schemas without automatic validation (we'll validate manually for better output)
|
|
199
|
+
if File.directory?(path)
|
|
200
|
+
load_schemas_from_dir_no_validate(registry, path)
|
|
201
|
+
else
|
|
202
|
+
registry.load_file(path, validate: false)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Run validation and collect errors
|
|
206
|
+
errors = registry.validate_paths
|
|
207
|
+
|
|
208
|
+
if errors.empty?
|
|
209
|
+
puts "✓ No lint errors found in #{registry.size} schema(s)"
|
|
210
|
+
if @options[:verbose]
|
|
211
|
+
puts "\nSchemas checked:"
|
|
212
|
+
registry.names.sort.each { |name| puts " - #{name}" }
|
|
213
|
+
end
|
|
214
|
+
EXIT_SUCCESS
|
|
215
|
+
else
|
|
216
|
+
puts "✗ Found #{errors.length} lint error(s):\n\n"
|
|
217
|
+
|
|
218
|
+
errors.each_with_index do |error, i|
|
|
219
|
+
puts "#{i + 1}. #{error.message}"
|
|
220
|
+
# :nocov:
|
|
221
|
+
if error.respond_to?(:suggestions) && error.suggestions&.any?
|
|
222
|
+
puts " Did you mean: #{error.suggestions.first(3).join(", ")}?"
|
|
223
|
+
end
|
|
224
|
+
# :nocov:
|
|
225
|
+
puts
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# In strict mode, treat warnings as errors
|
|
229
|
+
if @options[:strict]
|
|
230
|
+
$stderr.puts "Strict mode: #{errors.length} error(s) treated as failures"
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
EXIT_PARSE_ERROR
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Execute the preview command
|
|
238
|
+
#
|
|
239
|
+
# Generates a single record for quick schema testing.
|
|
240
|
+
# Always outputs pretty-printed JSON with schema info header.
|
|
241
|
+
#
|
|
242
|
+
# @return [Integer] exit code
|
|
243
|
+
#
|
|
244
|
+
|
|
245
|
+
def preview_command
|
|
246
|
+
parse_preview_options
|
|
247
|
+
schema_name = @args.shift
|
|
248
|
+
|
|
249
|
+
unless schema_name
|
|
250
|
+
$stderr.puts "Usage: synthra preview <SchemaName> [options]"
|
|
251
|
+
return EXIT_PARSE_ERROR
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
registry = Registry.new
|
|
255
|
+
load_schemas_from_dir(registry, @options[:dir])
|
|
256
|
+
|
|
257
|
+
schema = registry.schema(schema_name)
|
|
258
|
+
|
|
259
|
+
# Generate single record
|
|
260
|
+
options = { registry: registry, mode: @options[:mode] }
|
|
261
|
+
options[:seed] = @options[:seed] if @options[:seed]
|
|
262
|
+
|
|
263
|
+
record = schema.generate(**options)
|
|
264
|
+
|
|
265
|
+
# Output with header
|
|
266
|
+
puts "# #{schema_name} (#{schema.fields.length} fields)"
|
|
267
|
+
puts JSON.pretty_generate(record)
|
|
268
|
+
|
|
269
|
+
EXIT_SUCCESS
|
|
270
|
+
rescue KeyError
|
|
271
|
+
$stderr.puts "Unknown schema: #{schema_name}"
|
|
272
|
+
$stderr.puts "Available schemas: #{registry.schemas.keys.join(", ")}"
|
|
273
|
+
EXIT_PARSE_ERROR
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Execute the info command
|
|
277
|
+
#
|
|
278
|
+
# Shows information about schemas including version, fields, and deprecation status.
|
|
279
|
+
#
|
|
280
|
+
# @return [Integer] exit code
|
|
281
|
+
#
|
|
282
|
+
|
|
283
|
+
def info_command
|
|
284
|
+
parse_info_options
|
|
285
|
+
path = @args.shift
|
|
286
|
+
|
|
287
|
+
unless path
|
|
288
|
+
$stderr.puts "Usage: synthra info <path|directory> [options]"
|
|
289
|
+
return EXIT_PARSE_ERROR
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
registry = Registry.new
|
|
293
|
+
|
|
294
|
+
if File.directory?(path)
|
|
295
|
+
load_schemas_from_dir_no_validate(registry, path)
|
|
296
|
+
else
|
|
297
|
+
registry.load_file(path, validate: false)
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
schemas = registry.schemas
|
|
301
|
+
|
|
302
|
+
if schemas.empty?
|
|
303
|
+
puts "No schemas found."
|
|
304
|
+
return EXIT_SUCCESS
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
puts "=" * 60
|
|
308
|
+
puts "Schema Information"
|
|
309
|
+
puts "=" * 60
|
|
310
|
+
puts
|
|
311
|
+
|
|
312
|
+
schemas.each do |name, schema|
|
|
313
|
+
puts "📦 #{name}"
|
|
314
|
+
puts " Version: #{schema.version || 'not specified'}"
|
|
315
|
+
puts " Fields: #{schema.fields.length}"
|
|
316
|
+
puts " Behaviors: #{schema.behaviors.length}"
|
|
317
|
+
|
|
318
|
+
# :nocov:
|
|
319
|
+
if schema.deprecated?
|
|
320
|
+
puts " ⚠️ DEPRECATED: #{schema.deprecation_message || 'No message'}"
|
|
321
|
+
end
|
|
322
|
+
# :nocov:
|
|
323
|
+
|
|
324
|
+
if @options[:verbose]
|
|
325
|
+
puts " Field list:"
|
|
326
|
+
schema.fields.each do |field|
|
|
327
|
+
optional = field.optional? ? "?" : ""
|
|
328
|
+
nullable = field.nullable? ? "?" : ""
|
|
329
|
+
puts " - #{field.name}#{optional}: #{field.type_name}#{nullable}"
|
|
330
|
+
end
|
|
331
|
+
end
|
|
332
|
+
puts
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
EXIT_SUCCESS
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# Execute the diff command
|
|
339
|
+
#
|
|
340
|
+
# Compares two schema files/directories and shows differences.
|
|
341
|
+
#
|
|
342
|
+
# @return [Integer] exit code
|
|
343
|
+
#
|
|
344
|
+
|
|
345
|
+
def diff_command
|
|
346
|
+
parse_diff_options
|
|
347
|
+
path1 = @args.shift
|
|
348
|
+
path2 = @args.shift
|
|
349
|
+
|
|
350
|
+
unless path1 && path2
|
|
351
|
+
$stderr.puts "Usage: synthra diff <path1> <path2>"
|
|
352
|
+
return EXIT_PARSE_ERROR
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
registry1 = Registry.new
|
|
356
|
+
registry2 = Registry.new
|
|
357
|
+
|
|
358
|
+
load_path(registry1, path1)
|
|
359
|
+
load_path(registry2, path2)
|
|
360
|
+
|
|
361
|
+
schemas1 = registry1.schemas
|
|
362
|
+
schemas2 = registry2.schemas
|
|
363
|
+
|
|
364
|
+
all_names = (schemas1.keys + schemas2.keys).uniq.sort
|
|
365
|
+
|
|
366
|
+
puts "=" * 60
|
|
367
|
+
puts "Schema Diff"
|
|
368
|
+
puts "=" * 60
|
|
369
|
+
puts
|
|
370
|
+
|
|
371
|
+
has_diff = false
|
|
372
|
+
|
|
373
|
+
all_names.each do |name|
|
|
374
|
+
s1 = schemas1[name]
|
|
375
|
+
s2 = schemas2[name]
|
|
376
|
+
|
|
377
|
+
if s1.nil?
|
|
378
|
+
puts "➕ #{name} (added in #{path2})"
|
|
379
|
+
has_diff = true
|
|
380
|
+
elsif s2.nil?
|
|
381
|
+
puts "➖ #{name} (removed in #{path2})"
|
|
382
|
+
has_diff = true
|
|
383
|
+
else
|
|
384
|
+
diff = compare_schemas(s1, s2)
|
|
385
|
+
if diff.any?
|
|
386
|
+
puts "📝 #{name} (modified)"
|
|
387
|
+
diff.each { |d| puts " #{d}" }
|
|
388
|
+
has_diff = true
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
puts "✅ No differences found." unless has_diff
|
|
394
|
+
EXIT_SUCCESS
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Execute the repl command
|
|
398
|
+
#
|
|
399
|
+
# Starts an interactive REPL for testing schemas.
|
|
400
|
+
#
|
|
401
|
+
# @return [Integer] exit code
|
|
402
|
+
#
|
|
403
|
+
|
|
404
|
+
# :nocov:
|
|
405
|
+
def repl_command
|
|
406
|
+
parse_repl_options
|
|
407
|
+
require_relative "repl/formatter"
|
|
408
|
+
|
|
409
|
+
puts "=" * 60
|
|
410
|
+
puts "Synthra Interactive REPL (Enhanced)"
|
|
411
|
+
puts "=" * 60
|
|
412
|
+
puts
|
|
413
|
+
puts "Commands:"
|
|
414
|
+
puts " load <path> - Load schemas from file/directory"
|
|
415
|
+
puts " list - List loaded schemas"
|
|
416
|
+
puts " info <Schema> - Show schema details"
|
|
417
|
+
puts " gen <Schema> - Generate one record (JSON)"
|
|
418
|
+
puts " gen <Schema> N - Generate N records (JSON)"
|
|
419
|
+
puts " table <Schema> - Generate one record (table view)"
|
|
420
|
+
puts " table <Schema> N - Generate N records (table view)"
|
|
421
|
+
puts " debug <Schema> - Step-through debug generation"
|
|
422
|
+
puts " inspect <Schema> - Generate and show detailed inspection"
|
|
423
|
+
puts " seed <number> - Set seed for deterministic output"
|
|
424
|
+
puts " mode <mode> - Set mode (random/edge/invalid/hostile/mixed)"
|
|
425
|
+
puts " help - Show this help"
|
|
426
|
+
puts " quit/exit - Exit REPL"
|
|
427
|
+
puts
|
|
428
|
+
|
|
429
|
+
registry = Registry.new
|
|
430
|
+
current_seed = nil
|
|
431
|
+
current_mode = :random
|
|
432
|
+
|
|
433
|
+
loop do
|
|
434
|
+
print "synthra> "
|
|
435
|
+
input = $stdin.gets&.strip
|
|
436
|
+
break if input.nil?
|
|
437
|
+
|
|
438
|
+
case input
|
|
439
|
+
when /^load\s+(.+)$/
|
|
440
|
+
path = $1.strip
|
|
441
|
+
begin
|
|
442
|
+
if File.directory?(path)
|
|
443
|
+
load_schemas_from_dir_no_validate(registry, path)
|
|
444
|
+
else
|
|
445
|
+
registry.load_file(path, validate: false)
|
|
446
|
+
end
|
|
447
|
+
puts "✅ Loaded #{registry.size} schema(s)"
|
|
448
|
+
rescue => e
|
|
449
|
+
puts "❌ Error: #{e.message}"
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
when "list"
|
|
453
|
+
if registry.size == 0
|
|
454
|
+
puts "No schemas loaded. Use 'load <path>' first."
|
|
455
|
+
else
|
|
456
|
+
puts "Loaded schemas:"
|
|
457
|
+
registry.schemas.each do |name, schema|
|
|
458
|
+
version = schema.version ? " v#{schema.version}" : ""
|
|
459
|
+
deprecated = schema.deprecated? ? " [DEPRECATED]" : ""
|
|
460
|
+
puts " - #{name}#{version}#{deprecated}"
|
|
461
|
+
end
|
|
462
|
+
end
|
|
463
|
+
|
|
464
|
+
when /^info\s+(\w+)$/
|
|
465
|
+
name = $1
|
|
466
|
+
begin
|
|
467
|
+
schema = registry.schema(name)
|
|
468
|
+
puts "Schema: #{name}"
|
|
469
|
+
puts " Version: #{schema.version || 'not specified'}"
|
|
470
|
+
puts " Fields: #{schema.fields.length}"
|
|
471
|
+
schema.fields.each do |f|
|
|
472
|
+
optional = f.optional? ? "?" : ""
|
|
473
|
+
nullable = f.nullable? ? " (nullable)" : ""
|
|
474
|
+
puts " - #{f.name}#{optional}: #{f.type_name}#{nullable}"
|
|
475
|
+
end
|
|
476
|
+
rescue KeyError
|
|
477
|
+
puts "❌ Schema '#{name}' not found"
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
when /^gen\s+(\w+)(?:\s+(\d+))?$/
|
|
481
|
+
name = $1
|
|
482
|
+
count = ($2 || "1").to_i
|
|
483
|
+
begin
|
|
484
|
+
schema = registry.schema(name)
|
|
485
|
+
opts = { registry: registry, mode: current_mode }
|
|
486
|
+
opts[:seed] = current_seed if current_seed
|
|
487
|
+
|
|
488
|
+
if count == 1
|
|
489
|
+
record = schema.generate(**opts)
|
|
490
|
+
puts JSON.pretty_generate(record)
|
|
491
|
+
else
|
|
492
|
+
records = schema.generate_many(count, **opts)
|
|
493
|
+
puts JSON.pretty_generate(records)
|
|
494
|
+
end
|
|
495
|
+
rescue KeyError
|
|
496
|
+
puts "❌ Schema '#{name}' not found"
|
|
497
|
+
rescue => e
|
|
498
|
+
puts "❌ Error: #{e.message}"
|
|
499
|
+
puts e.backtrace.first(3).join("\n") if @options[:verbose]
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
when /^table\s+(\w+)(?:\s+(\d+))?$/
|
|
503
|
+
name = $1
|
|
504
|
+
count = ($2 || "1").to_i
|
|
505
|
+
begin
|
|
506
|
+
schema = registry.schema(name)
|
|
507
|
+
opts = { registry: registry, mode: current_mode }
|
|
508
|
+
opts[:seed] = current_seed if current_seed
|
|
509
|
+
|
|
510
|
+
if count == 1
|
|
511
|
+
record = schema.generate(**opts)
|
|
512
|
+
puts REPL::Formatter.table(record)
|
|
513
|
+
else
|
|
514
|
+
records = schema.generate_many(count, **opts)
|
|
515
|
+
puts REPL::Formatter.table(records)
|
|
516
|
+
end
|
|
517
|
+
rescue KeyError
|
|
518
|
+
puts "❌ Schema '#{name}' not found"
|
|
519
|
+
rescue => e
|
|
520
|
+
puts "❌ Error: #{e.message}"
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
when /^debug\s+(\w+)$/
|
|
524
|
+
name = $1
|
|
525
|
+
begin
|
|
526
|
+
schema = registry.schema(name)
|
|
527
|
+
debug_generation(schema, registry, current_mode, current_seed)
|
|
528
|
+
rescue KeyError
|
|
529
|
+
puts "❌ Schema '#{name}' not found"
|
|
530
|
+
rescue => e
|
|
531
|
+
puts "❌ Error: #{e.message}"
|
|
532
|
+
end
|
|
533
|
+
|
|
534
|
+
when /^inspect\s+(\w+)$/
|
|
535
|
+
name = $1
|
|
536
|
+
begin
|
|
537
|
+
schema = registry.schema(name)
|
|
538
|
+
opts = { registry: registry, mode: current_mode }
|
|
539
|
+
opts[:seed] = current_seed if current_seed
|
|
540
|
+
|
|
541
|
+
record = schema.generate(**opts)
|
|
542
|
+
puts "📊 Generated Record:"
|
|
543
|
+
puts "=" * 60
|
|
544
|
+
puts REPL::Formatter.table(record)
|
|
545
|
+
puts
|
|
546
|
+
puts "📋 JSON Format:"
|
|
547
|
+
puts JSON.pretty_generate(record)
|
|
548
|
+
puts
|
|
549
|
+
puts "📏 Statistics:"
|
|
550
|
+
puts " Fields: #{record.keys.length}"
|
|
551
|
+
puts " Total size: #{JSON.generate(record).bytesize} bytes"
|
|
552
|
+
record.each do |key, value|
|
|
553
|
+
type = value.class.name
|
|
554
|
+
size = case value
|
|
555
|
+
when String then value.bytesize
|
|
556
|
+
when Hash then "#{value.keys.length} keys"
|
|
557
|
+
when Array then "#{value.length} items"
|
|
558
|
+
else value.inspect.length
|
|
559
|
+
end
|
|
560
|
+
puts " #{key}: #{type} (#{size})"
|
|
561
|
+
end
|
|
562
|
+
rescue KeyError
|
|
563
|
+
puts "❌ Schema '#{name}' not found"
|
|
564
|
+
rescue => e
|
|
565
|
+
puts "❌ Error: #{e.message}"
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
when /^seed\s+(\d+)$/
|
|
569
|
+
current_seed = $1.to_i
|
|
570
|
+
puts "✅ Seed set to #{current_seed}"
|
|
571
|
+
|
|
572
|
+
when /^mode\s+(random|edge|invalid|hostile|mixed)$/
|
|
573
|
+
current_mode = $1.to_sym
|
|
574
|
+
puts "✅ Mode set to #{current_mode}"
|
|
575
|
+
|
|
576
|
+
when "help"
|
|
577
|
+
puts "Commands: load, list, info, gen, table, debug, inspect, seed, mode, help, quit"
|
|
578
|
+
|
|
579
|
+
when "quit", "exit"
|
|
580
|
+
puts "Goodbye!"
|
|
581
|
+
break
|
|
582
|
+
|
|
583
|
+
when ""
|
|
584
|
+
# Ignore empty input
|
|
585
|
+
|
|
586
|
+
else
|
|
587
|
+
puts "Unknown command. Type 'help' for available commands."
|
|
588
|
+
end
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
EXIT_SUCCESS
|
|
592
|
+
end
|
|
593
|
+
|
|
594
|
+
# Debug generation with step-through
|
|
595
|
+
#
|
|
596
|
+
# @param schema [Schema] schema to generate from
|
|
597
|
+
# @param registry [Registry] schema registry
|
|
598
|
+
# @param mode [Symbol] generation mode
|
|
599
|
+
# @param seed [Integer, nil] seed for determinism
|
|
600
|
+
#
|
|
601
|
+
def debug_generation(schema, registry, mode, seed)
|
|
602
|
+
puts "🐛 Debug Mode: Generating #{schema.name}"
|
|
603
|
+
puts "=" * 60
|
|
604
|
+
puts "Press Enter to continue after each field, 'q' to quit, 'c' to continue without pausing"
|
|
605
|
+
puts
|
|
606
|
+
|
|
607
|
+
field_index = 0
|
|
608
|
+
context_snapshot = nil
|
|
609
|
+
|
|
610
|
+
# Set up callback to intercept field generation
|
|
611
|
+
original_callback = Synthra.configuration.on_field_generated
|
|
612
|
+
|
|
613
|
+
Synthra.configuration.on_field_generated = lambda do |schema_name, field_name, value, duration|
|
|
614
|
+
field_index += 1
|
|
615
|
+
|
|
616
|
+
puts "\n[Field #{field_index}] #{field_name}"
|
|
617
|
+
puts "─" * 60
|
|
618
|
+
puts "Value: #{REPL::Formatter.format_value(value, 80)}"
|
|
619
|
+
puts "Duration: #{duration}ms" if duration
|
|
620
|
+
puts "Schema: #{schema_name}"
|
|
621
|
+
puts
|
|
622
|
+
|
|
623
|
+
loop do
|
|
624
|
+
print "Press Enter to continue, 'q' to quit, 'c' to continue without pausing: "
|
|
625
|
+
response = $stdin.gets&.strip&.downcase
|
|
626
|
+
|
|
627
|
+
case response
|
|
628
|
+
when "q", "quit"
|
|
629
|
+
Synthra.configuration.on_field_generated = original_callback
|
|
630
|
+
puts "Debug cancelled."
|
|
631
|
+
return
|
|
632
|
+
when "c", "continue"
|
|
633
|
+
Synthra.configuration.on_field_generated = original_callback
|
|
634
|
+
puts "Continuing without pausing..."
|
|
635
|
+
return
|
|
636
|
+
when "", nil
|
|
637
|
+
break
|
|
638
|
+
else
|
|
639
|
+
puts "Invalid input. Press Enter, 'q', or 'c'."
|
|
640
|
+
end
|
|
641
|
+
end
|
|
642
|
+
end
|
|
643
|
+
|
|
644
|
+
begin
|
|
645
|
+
opts = { registry: registry, mode: mode }
|
|
646
|
+
opts[:seed] = seed if seed
|
|
647
|
+
|
|
648
|
+
result = schema.generate(**opts)
|
|
649
|
+
|
|
650
|
+
puts
|
|
651
|
+
puts "=" * 60
|
|
652
|
+
puts "✅ Generation Complete!"
|
|
653
|
+
puts "=" * 60
|
|
654
|
+
puts
|
|
655
|
+
puts "Final Record:"
|
|
656
|
+
puts REPL::Formatter.table(result)
|
|
657
|
+
ensure
|
|
658
|
+
Synthra.configuration.on_field_generated = original_callback
|
|
659
|
+
end
|
|
660
|
+
end
|
|
661
|
+
# :nocov:
|
|
662
|
+
|
|
663
|
+
# Execute the export command
|
|
664
|
+
#
|
|
665
|
+
# Exports schema in various formats (JSON Schema, etc.)
|
|
666
|
+
#
|
|
667
|
+
# @return [Integer] exit code
|
|
668
|
+
#
|
|
669
|
+
|
|
670
|
+
def export_command
|
|
671
|
+
parse_export_options
|
|
672
|
+
schema_name = @args.shift
|
|
673
|
+
|
|
674
|
+
registry = Registry.new
|
|
675
|
+
load_schemas_from_dir_no_validate(registry, @options[:dir])
|
|
676
|
+
|
|
677
|
+
format = @options[:export_format]
|
|
678
|
+
|
|
679
|
+
# Handle "export all" mode for bulk exports
|
|
680
|
+
if @options[:all]
|
|
681
|
+
output = case format
|
|
682
|
+
when "typescript", "ts"
|
|
683
|
+
Export::Typescript.export_all(registry)
|
|
684
|
+
when "javascript", "js"
|
|
685
|
+
Export::Javascript.export_all(registry)
|
|
686
|
+
when "python", "py", "pydantic"
|
|
687
|
+
Export::Python.export_all(registry, style: @options[:python_style])
|
|
688
|
+
when "dataclass"
|
|
689
|
+
Export::Python.export_all(registry, style: :dataclass)
|
|
690
|
+
when "sql", "sql-ddl", "postgresql", "mysql", "sqlite"
|
|
691
|
+
Export::Sql.export_all(registry, dialect: @options[:sql_dialect])
|
|
692
|
+
when "graphviz", "dot"
|
|
693
|
+
Export::Graphviz.new(nil, registry: registry).export
|
|
694
|
+
when "protobuf", "proto"
|
|
695
|
+
Export::Protobuf.export_all(registry, package: @options[:package] || "generated")
|
|
696
|
+
when "openapi", "swagger"
|
|
697
|
+
Export::OpenAPI.new(registry,
|
|
698
|
+
title: @options[:title] || "Synthra API",
|
|
699
|
+
format: @options[:output]&.end_with?(".json") ? :json : :yaml
|
|
700
|
+
).export
|
|
701
|
+
else
|
|
702
|
+
$stderr.puts "Bulk export not supported for format: #{format}"
|
|
703
|
+
$stderr.puts "Supported: typescript, javascript, python, sql, graphviz, protobuf, openapi"
|
|
704
|
+
return EXIT_PARSE_ERROR
|
|
705
|
+
end
|
|
706
|
+
else
|
|
707
|
+
unless schema_name
|
|
708
|
+
$stderr.puts "Usage: synthra export <SchemaName> [options]"
|
|
709
|
+
$stderr.puts " synthra export --all [options]"
|
|
710
|
+
$stderr.puts ""
|
|
711
|
+
$stderr.puts "Schema formats: json-schema, typescript, javascript, sql, graphviz"
|
|
712
|
+
$stderr.puts "Data formats: json, csv, sql-insert, yaml, xml"
|
|
713
|
+
return EXIT_PARSE_ERROR
|
|
714
|
+
end
|
|
715
|
+
|
|
716
|
+
schema = registry.schema(schema_name)
|
|
717
|
+
export_opts = build_export_options(registry)
|
|
718
|
+
|
|
719
|
+
output = case format
|
|
720
|
+
# Schema export formats
|
|
721
|
+
when "json-schema"
|
|
722
|
+
Export::JsonSchema.new(schema, **export_opts).export
|
|
723
|
+
when "typescript", "ts"
|
|
724
|
+
Export::Typescript.new(schema, **export_opts).export
|
|
725
|
+
when "javascript", "js"
|
|
726
|
+
Export::Javascript.new(schema, **export_opts).export
|
|
727
|
+
when "python", "py", "pydantic"
|
|
728
|
+
Export::Python.new(schema, **export_opts.merge(style: @options[:python_style])).export
|
|
729
|
+
when "dataclass"
|
|
730
|
+
Export::Python.new(schema, **export_opts.merge(style: :dataclass)).export
|
|
731
|
+
when "sql", "sql-ddl", "postgresql", "mysql", "sqlite"
|
|
732
|
+
dialect = %w[postgresql mysql sqlite].include?(format) ? format.to_sym : @options[:sql_dialect]
|
|
733
|
+
Export::Sql.new(schema, registry: registry, dialect: dialect).export
|
|
734
|
+
when "graphviz", "dot"
|
|
735
|
+
Export::Graphviz.new(schema, **export_opts).export
|
|
736
|
+
when "protobuf", "proto"
|
|
737
|
+
Export::Protobuf.new(schema, **export_opts.merge(
|
|
738
|
+
package: @options[:package] || "generated"
|
|
739
|
+
)).export
|
|
740
|
+
when "openapi", "swagger"
|
|
741
|
+
mini_registry = Registry.new
|
|
742
|
+
mini_registry.register_schema(schema.name, schema)
|
|
743
|
+
Export::OpenAPI.new(mini_registry,
|
|
744
|
+
title: @options[:title] || "#{schema.name} API",
|
|
745
|
+
format: @options[:output]&.end_with?(".json") ? :json : :yaml
|
|
746
|
+
).export
|
|
747
|
+
|
|
748
|
+
# Data export formats
|
|
749
|
+
when "json", "json-data"
|
|
750
|
+
Export::JsonData.new(schema, **export_opts.merge(
|
|
751
|
+
pretty: @options[:pretty],
|
|
752
|
+
envelope: @options[:envelope]
|
|
753
|
+
)).export
|
|
754
|
+
when "csv"
|
|
755
|
+
Export::Csv.new(schema, **export_opts).export
|
|
756
|
+
when "sql-insert", "insert"
|
|
757
|
+
Export::SqlInsert.new(schema, **export_opts.merge(
|
|
758
|
+
dialect: @options[:sql_dialect],
|
|
759
|
+
batch_insert: @options[:batch_insert]
|
|
760
|
+
)).export
|
|
761
|
+
when "yaml", "yml"
|
|
762
|
+
Export::YamlData.new(schema, **export_opts).export
|
|
763
|
+
when "xml"
|
|
764
|
+
Export::XmlData.new(schema, **export_opts.merge(
|
|
765
|
+
root: @options[:root],
|
|
766
|
+
item: @options[:item]
|
|
767
|
+
)).export
|
|
768
|
+
else
|
|
769
|
+
$stderr.puts "Unknown export format: #{format}"
|
|
770
|
+
$stderr.puts ""
|
|
771
|
+
$stderr.puts "Schema formats: json-schema, typescript, javascript, python, sql, graphviz, protobuf, openapi"
|
|
772
|
+
$stderr.puts "Data formats: json, csv, sql-insert, yaml, xml"
|
|
773
|
+
return EXIT_PARSE_ERROR
|
|
774
|
+
end
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# Determine output path
|
|
778
|
+
output_path = if @options[:output]
|
|
779
|
+
@options[:output]
|
|
780
|
+
elsif @options[:auto_file]
|
|
781
|
+
output_dir = @options[:output_dir] || "."
|
|
782
|
+
filename = Export.auto_filename(
|
|
783
|
+
@options[:all] ? "all_schemas" : schema_name,
|
|
784
|
+
format
|
|
785
|
+
)
|
|
786
|
+
File.join(output_dir, filename)
|
|
787
|
+
else
|
|
788
|
+
nil
|
|
789
|
+
end
|
|
790
|
+
|
|
791
|
+
if output_path
|
|
792
|
+
FileUtils.mkdir_p(File.dirname(output_path)) if File.dirname(output_path) != "."
|
|
793
|
+
File.write(output_path, output)
|
|
794
|
+
puts "✓ Exported to #{output_path}"
|
|
795
|
+
else
|
|
796
|
+
puts output
|
|
797
|
+
end
|
|
798
|
+
|
|
799
|
+
EXIT_SUCCESS
|
|
800
|
+
rescue KeyError
|
|
801
|
+
$stderr.puts "Unknown schema: #{schema_name}"
|
|
802
|
+
$stderr.puts "Available schemas: #{registry.names.join(", ")}"
|
|
803
|
+
EXIT_PARSE_ERROR
|
|
804
|
+
end
|
|
805
|
+
|
|
806
|
+
def build_export_options(registry)
|
|
807
|
+
{
|
|
808
|
+
registry: registry,
|
|
809
|
+
count: @options[:count],
|
|
810
|
+
seed: @options[:seed],
|
|
811
|
+
mode: @options[:mode] || :random
|
|
812
|
+
}.compact
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# Export schema as JSON Schema
|
|
816
|
+
#
|
|
817
|
+
# @param schema [Schema] schema to export
|
|
818
|
+
# @param registry [Registry] registry for resolving references
|
|
819
|
+
# @return [String] JSON Schema as JSON string
|
|
820
|
+
#
|
|
821
|
+
# :nocov:
|
|
822
|
+
def export_json_schema(schema, registry)
|
|
823
|
+
json_schema = {
|
|
824
|
+
"$schema" => "https://json-schema.org/draft/2020-12/schema",
|
|
825
|
+
"$id" => "#{schema.name.downcase}.json",
|
|
826
|
+
"title" => schema.name,
|
|
827
|
+
"type" => "object",
|
|
828
|
+
"properties" => {},
|
|
829
|
+
"required" => []
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
schema.fields.each do |field|
|
|
833
|
+
prop = field_to_json_schema(field, registry)
|
|
834
|
+
json_schema["properties"][field.name] = prop
|
|
835
|
+
json_schema["required"] << field.name unless field.optional?
|
|
836
|
+
end
|
|
837
|
+
|
|
838
|
+
JSON.pretty_generate(json_schema)
|
|
839
|
+
end
|
|
840
|
+
|
|
841
|
+
# Convert a field to JSON Schema property
|
|
842
|
+
#
|
|
843
|
+
# @param field [Field] field to convert
|
|
844
|
+
# @param registry [Registry] registry for resolving references
|
|
845
|
+
# @return [Hash] JSON Schema property definition
|
|
846
|
+
#
|
|
847
|
+
|
|
848
|
+
def field_to_json_schema(field, registry)
|
|
849
|
+
type_name = field.type_name
|
|
850
|
+
prop = {}
|
|
851
|
+
|
|
852
|
+
case type_name
|
|
853
|
+
when "uuid", "ulid"
|
|
854
|
+
prop = { "type" => "string", "format" => "uuid" }
|
|
855
|
+
when "email"
|
|
856
|
+
prop = { "type" => "string", "format" => "email" }
|
|
857
|
+
when "url"
|
|
858
|
+
prop = { "type" => "string", "format" => "uri" }
|
|
859
|
+
when "date", "past_date", "future_date"
|
|
860
|
+
prop = { "type" => "string", "format" => "date" }
|
|
861
|
+
when "timestamp", "now"
|
|
862
|
+
prop = { "type" => "string", "format" => "date-time" }
|
|
863
|
+
when "number", "integer", "id_sequence"
|
|
864
|
+
prop = { "type" => "integer" }
|
|
865
|
+
if field.type_args[:range]
|
|
866
|
+
range = field.type_args[:range]
|
|
867
|
+
prop["minimum"] = range.min if range.respond_to?(:min)
|
|
868
|
+
prop["maximum"] = range.max if range.respond_to?(:max)
|
|
869
|
+
end
|
|
870
|
+
when "float", "money"
|
|
871
|
+
prop = { "type" => "number" }
|
|
872
|
+
when "boolean"
|
|
873
|
+
prop = { "type" => "boolean" }
|
|
874
|
+
when "text", "name", "full_name", "first_name", "last_name", "phone", "city", "country", "country_code", "postal_code"
|
|
875
|
+
prop = { "type" => "string" }
|
|
876
|
+
when "enum"
|
|
877
|
+
values = field.type_args[:values]&.map { |v| v.respond_to?(:value) ? v.value : v } || []
|
|
878
|
+
prop = { "type" => "string", "enum" => values }
|
|
879
|
+
when "const"
|
|
880
|
+
value = field.type_args[:value]
|
|
881
|
+
prop = { "const" => value }
|
|
882
|
+
when "array"
|
|
883
|
+
element_type = field.type_args[:element]
|
|
884
|
+
size = field.type_args[:size]
|
|
885
|
+
prop = { "type" => "array" }
|
|
886
|
+
if element_type && registry.schema?(element_type.to_s)
|
|
887
|
+
prop["items"] = { "$ref" => "#/$defs/#{element_type}" }
|
|
888
|
+
else
|
|
889
|
+
prop["items"] = { "type" => "string" }
|
|
890
|
+
end
|
|
891
|
+
if size.is_a?(Range)
|
|
892
|
+
prop["minItems"] = size.min
|
|
893
|
+
prop["maxItems"] = size.max
|
|
894
|
+
elsif size.is_a?(Hash)
|
|
895
|
+
prop["minItems"] = size[:min] if size[:min]
|
|
896
|
+
prop["maxItems"] = size[:max] if size[:max]
|
|
897
|
+
end
|
|
898
|
+
else
|
|
899
|
+
# Assume it's a schema reference
|
|
900
|
+
if registry.schema?(type_name)
|
|
901
|
+
prop = { "$ref" => "#/$defs/#{type_name}" }
|
|
902
|
+
else
|
|
903
|
+
prop = { "type" => "string" }
|
|
904
|
+
end
|
|
905
|
+
end
|
|
906
|
+
|
|
907
|
+
prop["nullable"] = true if field.nullable?
|
|
908
|
+
prop
|
|
909
|
+
end
|
|
910
|
+
# :nocov:
|
|
911
|
+
|
|
912
|
+
# Export schema as TypeScript interface
|
|
913
|
+
#
|
|
914
|
+
# @param schema [Schema] schema to export
|
|
915
|
+
# @param registry [Registry] registry for resolving references
|
|
916
|
+
# @return [String] TypeScript interface definition
|
|
917
|
+
#
|
|
918
|
+
# :nocov:
|
|
919
|
+
def export_typescript(schema, registry)
|
|
920
|
+
lines = ["interface #{schema.name} {"]
|
|
921
|
+
|
|
922
|
+
schema.fields.each do |field|
|
|
923
|
+
ts_type = field_to_typescript(field, registry)
|
|
924
|
+
optional = field.optional? ? "?" : ""
|
|
925
|
+
nullable = field.nullable? ? " | null" : ""
|
|
926
|
+
lines << " #{field.name}#{optional}: #{ts_type}#{nullable};"
|
|
927
|
+
end
|
|
928
|
+
|
|
929
|
+
lines << "}"
|
|
930
|
+
lines.join("\n")
|
|
931
|
+
end
|
|
932
|
+
|
|
933
|
+
# Convert a field to TypeScript type
|
|
934
|
+
#
|
|
935
|
+
# @param field [Field] field to convert
|
|
936
|
+
# @param registry [Registry] registry for resolving references
|
|
937
|
+
# @return [String] TypeScript type
|
|
938
|
+
#
|
|
939
|
+
|
|
940
|
+
def field_to_typescript(field, registry)
|
|
941
|
+
type_name = field.type_name
|
|
942
|
+
|
|
943
|
+
case type_name
|
|
944
|
+
when "uuid", "ulid", "email", "url", "date", "past_date", "future_date", "timestamp", "now", "text", "name", "full_name", "first_name", "last_name", "phone", "city", "country", "country_code", "postal_code"
|
|
945
|
+
"string"
|
|
946
|
+
when "number", "integer", "id_sequence", "float", "money"
|
|
947
|
+
"number"
|
|
948
|
+
when "boolean"
|
|
949
|
+
"boolean"
|
|
950
|
+
when "enum"
|
|
951
|
+
values = field.type_args[:values]&.map { |v| "\"#{v.respond_to?(:value) ? v.value : v}\"" } || []
|
|
952
|
+
values.join(" | ")
|
|
953
|
+
when "const"
|
|
954
|
+
value = field.type_args[:value]
|
|
955
|
+
value.is_a?(String) ? "\"#{value}\"" : value.to_s
|
|
956
|
+
when "array"
|
|
957
|
+
element_type = field.type_args[:element]
|
|
958
|
+
if element_type && registry.schema?(element_type.to_s)
|
|
959
|
+
"#{element_type}[]"
|
|
960
|
+
else
|
|
961
|
+
"string[]"
|
|
962
|
+
end
|
|
963
|
+
else
|
|
964
|
+
registry.schema?(type_name) ? type_name : "any"
|
|
965
|
+
end
|
|
966
|
+
end
|
|
967
|
+
# :nocov:
|
|
968
|
+
|
|
969
|
+
# Parse options for export command
|
|
970
|
+
#
|
|
971
|
+
# @return [void]
|
|
972
|
+
#
|
|
973
|
+
|
|
974
|
+
def parse_export_options
|
|
975
|
+
@options[:export_format] = "json-schema"
|
|
976
|
+
@options[:output] = nil
|
|
977
|
+
@options[:output_dir] = nil
|
|
978
|
+
@options[:auto_file] = false
|
|
979
|
+
@options[:sql_dialect] = :postgresql
|
|
980
|
+
@options[:count] = 10
|
|
981
|
+
@options[:all] = false
|
|
982
|
+
@options[:pretty] = true
|
|
983
|
+
@options[:batch_insert] = false
|
|
984
|
+
@options[:envelope] = nil
|
|
985
|
+
@options[:root] = nil
|
|
986
|
+
@options[:item] = nil
|
|
987
|
+
@options[:python_style] = :pydantic
|
|
988
|
+
|
|
989
|
+
OptionParser.new do |opts|
|
|
990
|
+
opts.banner = "Usage: synthra export <SchemaName> [options]"
|
|
991
|
+
|
|
992
|
+
opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
|
|
993
|
+
@options[:dir] = v
|
|
994
|
+
end
|
|
995
|
+
|
|
996
|
+
opts.on("-f", "--format FORMAT", "Export format (see list below)") do |v|
|
|
997
|
+
@options[:export_format] = v
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
opts.on("-o", "--output FILE", "Output file (default: stdout)") do |v|
|
|
1001
|
+
@options[:output] = v
|
|
1002
|
+
end
|
|
1003
|
+
|
|
1004
|
+
opts.on("--out-dir DIR", "Output directory for auto-generated files") do |v|
|
|
1005
|
+
@options[:output_dir] = v
|
|
1006
|
+
@options[:auto_file] = true
|
|
1007
|
+
end
|
|
1008
|
+
|
|
1009
|
+
opts.on("--auto-file", "Auto-generate filename based on schema and format") do
|
|
1010
|
+
@options[:auto_file] = true
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
opts.on("--dialect DIALECT", "SQL dialect: postgresql, mysql, sqlite") do |v|
|
|
1014
|
+
@options[:sql_dialect] = v.to_sym
|
|
1015
|
+
end
|
|
1016
|
+
|
|
1017
|
+
opts.on("-c", "--count COUNT", Integer, "Number of records for data exports (default: 10)") do |v|
|
|
1018
|
+
@options[:count] = v
|
|
1019
|
+
end
|
|
1020
|
+
|
|
1021
|
+
opts.on("-s", "--seed SEED", Integer, "Seed for deterministic generation") do |v|
|
|
1022
|
+
@options[:seed] = v
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
opts.on("-a", "--all", "Export all schemas (typescript/javascript/python/sql)") do
|
|
1026
|
+
@options[:all] = true
|
|
1027
|
+
end
|
|
1028
|
+
|
|
1029
|
+
opts.on("--batch", "Use batch INSERT for sql-insert format") do
|
|
1030
|
+
@options[:batch_insert] = true
|
|
1031
|
+
end
|
|
1032
|
+
|
|
1033
|
+
opts.on("--compact", "Compact output (no pretty-print)") do
|
|
1034
|
+
@options[:pretty] = false
|
|
1035
|
+
end
|
|
1036
|
+
|
|
1037
|
+
opts.on("--envelope KEY", "Wrap JSON output in envelope with this key") do |v|
|
|
1038
|
+
@options[:envelope] = v
|
|
1039
|
+
end
|
|
1040
|
+
|
|
1041
|
+
opts.on("--root NAME", "Root element name for XML") do |v|
|
|
1042
|
+
@options[:root] = v
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
opts.on("--item NAME", "Item element name for XML") do |v|
|
|
1046
|
+
@options[:item] = v
|
|
1047
|
+
end
|
|
1048
|
+
|
|
1049
|
+
opts.on("--style STYLE", "Python style: pydantic, dataclass, typed_dict") do |v|
|
|
1050
|
+
@options[:python_style] = v.to_sym
|
|
1051
|
+
end
|
|
1052
|
+
end.parse!(@args)
|
|
1053
|
+
end
|
|
1054
|
+
|
|
1055
|
+
# Execute the graph command
|
|
1056
|
+
#
|
|
1057
|
+
# Generates GraphViz DOT output for schema visualization.
|
|
1058
|
+
#
|
|
1059
|
+
# @return [Integer] exit code
|
|
1060
|
+
#
|
|
1061
|
+
|
|
1062
|
+
def graph_command
|
|
1063
|
+
parse_graph_options
|
|
1064
|
+
path = @args.shift || @options[:dir]
|
|
1065
|
+
|
|
1066
|
+
registry = Registry.new
|
|
1067
|
+
if File.directory?(path)
|
|
1068
|
+
load_schemas_from_dir_no_validate(registry, path)
|
|
1069
|
+
else
|
|
1070
|
+
registry.load_file(path, validate: false)
|
|
1071
|
+
end
|
|
1072
|
+
|
|
1073
|
+
# Use the Export::Graphviz module
|
|
1074
|
+
exporter = Export::Graphviz.new(nil, registry: registry,
|
|
1075
|
+
direction: @options[:direction] || "LR",
|
|
1076
|
+
group: @options[:group])
|
|
1077
|
+
dot_output = exporter.export
|
|
1078
|
+
|
|
1079
|
+
if @options[:output]
|
|
1080
|
+
File.write(@options[:output], dot_output)
|
|
1081
|
+
puts "✓ Generated graph to #{@options[:output]}"
|
|
1082
|
+
|
|
1083
|
+
# Try to render PNG/SVG if graphviz is installed
|
|
1084
|
+
# :nocov:
|
|
1085
|
+
if @options[:render] && system("which dot > /dev/null 2>&1")
|
|
1086
|
+
render_format = @options[:render_format] || "png"
|
|
1087
|
+
output_file = @options[:output].sub(/\.dot$/, ".#{render_format}")
|
|
1088
|
+
system("dot", "-T#{render_format}", @options[:output], "-o", output_file)
|
|
1089
|
+
puts "✓ Rendered #{render_format.upcase} to #{output_file}"
|
|
1090
|
+
end
|
|
1091
|
+
# :nocov:
|
|
1092
|
+
else
|
|
1093
|
+
puts dot_output
|
|
1094
|
+
end
|
|
1095
|
+
|
|
1096
|
+
EXIT_SUCCESS
|
|
1097
|
+
end
|
|
1098
|
+
|
|
1099
|
+
# Generate GraphViz DOT representation of schemas
|
|
1100
|
+
#
|
|
1101
|
+
# @param registry [Registry] registry containing schemas
|
|
1102
|
+
# @return [String] DOT format string
|
|
1103
|
+
#
|
|
1104
|
+
# :nocov:
|
|
1105
|
+
def generate_graphviz(registry)
|
|
1106
|
+
lines = [
|
|
1107
|
+
"digraph Synthra {",
|
|
1108
|
+
" rankdir=LR;",
|
|
1109
|
+
" node [shape=record, fontname=\"Helvetica\"];",
|
|
1110
|
+
" edge [fontname=\"Helvetica\", fontsize=10];",
|
|
1111
|
+
""
|
|
1112
|
+
]
|
|
1113
|
+
|
|
1114
|
+
registry.schemas.each do |name, schema|
|
|
1115
|
+
# Create node for schema
|
|
1116
|
+
fields = schema.fields.map do |f|
|
|
1117
|
+
optional = f.optional? ? "?" : ""
|
|
1118
|
+
nullable = f.nullable? ? "?" : ""
|
|
1119
|
+
"#{f.name}#{optional}: #{f.type_name}#{nullable}"
|
|
1120
|
+
end
|
|
1121
|
+
|
|
1122
|
+
label = "{#{name}|#{fields.join("\\l")}\\l}"
|
|
1123
|
+
color = schema.deprecated? ? "gray" : "black"
|
|
1124
|
+
lines << " #{name} [label=\"#{label}\", color=#{color}];"
|
|
1125
|
+
end
|
|
1126
|
+
|
|
1127
|
+
lines << ""
|
|
1128
|
+
|
|
1129
|
+
# Create edges for references
|
|
1130
|
+
registry.schemas.each do |name, schema|
|
|
1131
|
+
schema.fields.each do |field|
|
|
1132
|
+
type_name = field.type_name
|
|
1133
|
+
|
|
1134
|
+
# Direct schema reference
|
|
1135
|
+
if registry.schema?(type_name)
|
|
1136
|
+
lines << " #{name} -> #{type_name} [label=\"#{field.name}\"];"
|
|
1137
|
+
end
|
|
1138
|
+
|
|
1139
|
+
# Array element type
|
|
1140
|
+
if type_name == "array"
|
|
1141
|
+
element = field.type_args[:element]
|
|
1142
|
+
if element && registry.schema?(element.to_s)
|
|
1143
|
+
lines << " #{name} -> #{element} [label=\"#{field.name}[]\", style=dashed];"
|
|
1144
|
+
end
|
|
1145
|
+
end
|
|
1146
|
+
|
|
1147
|
+
# map_by_field entries
|
|
1148
|
+
if type_name == "map_by_field"
|
|
1149
|
+
entries = field.type_args[:entries] || {}
|
|
1150
|
+
entries.each do |entry_name, entry_info|
|
|
1151
|
+
schema_name = entry_info.is_a?(Hash) ? entry_info[:schema] : entry_info.to_s
|
|
1152
|
+
if schema_name && registry.schema?(schema_name)
|
|
1153
|
+
lines << " #{name} -> #{schema_name} [label=\"#{field.name}.#{entry_name}\", style=dotted];"
|
|
1154
|
+
end
|
|
1155
|
+
end
|
|
1156
|
+
end
|
|
1157
|
+
end
|
|
1158
|
+
end
|
|
1159
|
+
|
|
1160
|
+
lines << "}"
|
|
1161
|
+
lines.join("\n")
|
|
1162
|
+
end
|
|
1163
|
+
# :nocov:
|
|
1164
|
+
|
|
1165
|
+
# Parse options for graph command
|
|
1166
|
+
#
|
|
1167
|
+
# @return [void]
|
|
1168
|
+
#
|
|
1169
|
+
|
|
1170
|
+
def parse_graph_options
|
|
1171
|
+
@options[:output] = nil
|
|
1172
|
+
@options[:render] = false
|
|
1173
|
+
@options[:render_format] = "png"
|
|
1174
|
+
@options[:direction] = "LR"
|
|
1175
|
+
@options[:group] = false
|
|
1176
|
+
|
|
1177
|
+
OptionParser.new do |opts|
|
|
1178
|
+
opts.banner = "Usage: synthra graph <path|directory> [options]"
|
|
1179
|
+
|
|
1180
|
+
opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
|
|
1181
|
+
@options[:dir] = v
|
|
1182
|
+
end
|
|
1183
|
+
|
|
1184
|
+
opts.on("-o", "--output FILE", "Output DOT file") do |v|
|
|
1185
|
+
@options[:output] = v
|
|
1186
|
+
end
|
|
1187
|
+
|
|
1188
|
+
opts.on("-r", "--render", "Also render image (requires graphviz)") do
|
|
1189
|
+
@options[:render] = true
|
|
1190
|
+
end
|
|
1191
|
+
|
|
1192
|
+
opts.on("--format FORMAT", "Render format: png, svg, pdf (default: png)") do |v|
|
|
1193
|
+
@options[:render_format] = v
|
|
1194
|
+
end
|
|
1195
|
+
|
|
1196
|
+
opts.on("--direction DIR", "Graph direction: LR, TB, BT, RL (default: LR)") do |v|
|
|
1197
|
+
@options[:direction] = v.upcase
|
|
1198
|
+
end
|
|
1199
|
+
|
|
1200
|
+
opts.on("-g", "--group", "Group schemas by category") do
|
|
1201
|
+
@options[:group] = true
|
|
1202
|
+
end
|
|
1203
|
+
end.parse!(@args)
|
|
1204
|
+
end
|
|
1205
|
+
|
|
1206
|
+
# Execute the watch command
|
|
1207
|
+
#
|
|
1208
|
+
# Watches for schema changes and validates on change.
|
|
1209
|
+
#
|
|
1210
|
+
# @return [Integer] exit code
|
|
1211
|
+
#
|
|
1212
|
+
|
|
1213
|
+
# :nocov:
|
|
1214
|
+
def watch_command
|
|
1215
|
+
parse_watch_options
|
|
1216
|
+
path = @args.shift || @options[:dir]
|
|
1217
|
+
|
|
1218
|
+
unless File.directory?(path)
|
|
1219
|
+
$stderr.puts "Watch requires a directory path"
|
|
1220
|
+
return EXIT_PARSE_ERROR
|
|
1221
|
+
end
|
|
1222
|
+
|
|
1223
|
+
puts "👁 Watching #{path} for changes..."
|
|
1224
|
+
puts " Press Ctrl+C to stop"
|
|
1225
|
+
puts
|
|
1226
|
+
|
|
1227
|
+
# Track file modification times
|
|
1228
|
+
mtimes = {}
|
|
1229
|
+
Dir.glob(File.join(path, "*.dsl")).each do |f|
|
|
1230
|
+
mtimes[f] = File.mtime(f)
|
|
1231
|
+
end
|
|
1232
|
+
|
|
1233
|
+
loop do
|
|
1234
|
+
sleep(@options[:interval] || 1)
|
|
1235
|
+
|
|
1236
|
+
# Check for new or modified files
|
|
1237
|
+
current_files = Dir.glob(File.join(path, "*.dsl"))
|
|
1238
|
+
|
|
1239
|
+
current_files.each do |f|
|
|
1240
|
+
current_mtime = File.mtime(f)
|
|
1241
|
+
|
|
1242
|
+
if mtimes[f].nil?
|
|
1243
|
+
puts "➕ New file: #{File.basename(f)}"
|
|
1244
|
+
validate_and_report(f)
|
|
1245
|
+
mtimes[f] = current_mtime
|
|
1246
|
+
elsif mtimes[f] < current_mtime
|
|
1247
|
+
puts "📝 Changed: #{File.basename(f)}"
|
|
1248
|
+
validate_and_report(f)
|
|
1249
|
+
mtimes[f] = current_mtime
|
|
1250
|
+
end
|
|
1251
|
+
end
|
|
1252
|
+
|
|
1253
|
+
# Check for deleted files
|
|
1254
|
+
(mtimes.keys - current_files).each do |f|
|
|
1255
|
+
puts "➖ Deleted: #{File.basename(f)}"
|
|
1256
|
+
mtimes.delete(f)
|
|
1257
|
+
end
|
|
1258
|
+
end
|
|
1259
|
+
rescue Interrupt
|
|
1260
|
+
puts "\n👋 Stopped watching"
|
|
1261
|
+
EXIT_SUCCESS
|
|
1262
|
+
end
|
|
1263
|
+
# :nocov:
|
|
1264
|
+
|
|
1265
|
+
# Validate a file and report results
|
|
1266
|
+
#
|
|
1267
|
+
# @param path [String] file path
|
|
1268
|
+
# @return [void]
|
|
1269
|
+
#
|
|
1270
|
+
# :nocov:
|
|
1271
|
+
def validate_and_report(path)
|
|
1272
|
+
registry = Registry.new
|
|
1273
|
+
begin
|
|
1274
|
+
registry.load_file(path)
|
|
1275
|
+
errors = registry.validate_paths
|
|
1276
|
+
|
|
1277
|
+
if errors.empty?
|
|
1278
|
+
puts " ✓ Valid"
|
|
1279
|
+
else
|
|
1280
|
+
puts " ✗ #{errors.length} error(s)"
|
|
1281
|
+
errors.each { |e| puts " - #{e.message}" }
|
|
1282
|
+
end
|
|
1283
|
+
rescue Synthra::Errors::ParseError => e
|
|
1284
|
+
puts " ✗ Parse error: #{e.message}"
|
|
1285
|
+
end
|
|
1286
|
+
puts
|
|
1287
|
+
end
|
|
1288
|
+
# :nocov:
|
|
1289
|
+
|
|
1290
|
+
# Parse options for watch command
|
|
1291
|
+
#
|
|
1292
|
+
# @return [void]
|
|
1293
|
+
#
|
|
1294
|
+
# :nocov:
|
|
1295
|
+
def parse_watch_options
|
|
1296
|
+
@options[:interval] = 1
|
|
1297
|
+
|
|
1298
|
+
OptionParser.new do |opts|
|
|
1299
|
+
opts.banner = "Usage: synthra watch <directory>"
|
|
1300
|
+
|
|
1301
|
+
opts.on("-i", "--interval SECONDS", Integer, "Check interval in seconds") do |v|
|
|
1302
|
+
@options[:interval] = v
|
|
1303
|
+
end
|
|
1304
|
+
end.parse!(@args)
|
|
1305
|
+
end
|
|
1306
|
+
# :nocov:
|
|
1307
|
+
|
|
1308
|
+
# Load schemas from a path (file or directory)
|
|
1309
|
+
#
|
|
1310
|
+
# @param registry [Registry] registry to load into
|
|
1311
|
+
# @param path [String] file or directory path
|
|
1312
|
+
# @return [void]
|
|
1313
|
+
#
|
|
1314
|
+
|
|
1315
|
+
def load_path(registry, path)
|
|
1316
|
+
if File.directory?(path)
|
|
1317
|
+
load_schemas_from_dir_no_validate(registry, path)
|
|
1318
|
+
else
|
|
1319
|
+
registry.load_file(path, validate: false)
|
|
1320
|
+
end
|
|
1321
|
+
end
|
|
1322
|
+
|
|
1323
|
+
# Compare two schemas and return differences
|
|
1324
|
+
#
|
|
1325
|
+
# @param s1 [Schema] first schema
|
|
1326
|
+
# @param s2 [Schema] second schema
|
|
1327
|
+
# @return [Array<String>] list of differences
|
|
1328
|
+
#
|
|
1329
|
+
|
|
1330
|
+
def compare_schemas(s1, s2)
|
|
1331
|
+
diff = []
|
|
1332
|
+
|
|
1333
|
+
# Compare versions
|
|
1334
|
+
# :nocov:
|
|
1335
|
+
if s1.version != s2.version
|
|
1336
|
+
diff << "version: #{s1.version || 'nil'} → #{s2.version || 'nil'}"
|
|
1337
|
+
end
|
|
1338
|
+
# :nocov:
|
|
1339
|
+
|
|
1340
|
+
# Compare deprecation
|
|
1341
|
+
# :nocov:
|
|
1342
|
+
if s1.deprecated? != s2.deprecated?
|
|
1343
|
+
diff << "deprecated: #{s1.deprecated?} → #{s2.deprecated?}"
|
|
1344
|
+
end
|
|
1345
|
+
# :nocov:
|
|
1346
|
+
|
|
1347
|
+
# Compare fields
|
|
1348
|
+
f1_names = s1.fields.map(&:name)
|
|
1349
|
+
f2_names = s2.fields.map(&:name)
|
|
1350
|
+
|
|
1351
|
+
(f2_names - f1_names).each { |f| diff << "+ field: #{f}" }
|
|
1352
|
+
(f1_names - f2_names).each { |f| diff << "- field: #{f}" }
|
|
1353
|
+
|
|
1354
|
+
# Compare field types
|
|
1355
|
+
(f1_names & f2_names).each do |name|
|
|
1356
|
+
field1 = s1.field(name)
|
|
1357
|
+
field2 = s2.field(name)
|
|
1358
|
+
if field1.type_name != field2.type_name
|
|
1359
|
+
diff << "~ #{name}: #{field1.type_name} → #{field2.type_name}"
|
|
1360
|
+
end
|
|
1361
|
+
end
|
|
1362
|
+
|
|
1363
|
+
diff
|
|
1364
|
+
end
|
|
1365
|
+
|
|
1366
|
+
# Parse options for info command
|
|
1367
|
+
#
|
|
1368
|
+
# @return [void]
|
|
1369
|
+
#
|
|
1370
|
+
|
|
1371
|
+
def parse_info_options
|
|
1372
|
+
@options[:verbose] = false
|
|
1373
|
+
|
|
1374
|
+
OptionParser.new do |opts|
|
|
1375
|
+
opts.banner = "Usage: synthra info <path|directory>"
|
|
1376
|
+
|
|
1377
|
+
opts.on("-v", "--verbose", "Show field details") do
|
|
1378
|
+
@options[:verbose] = true
|
|
1379
|
+
end
|
|
1380
|
+
end.parse!(@args)
|
|
1381
|
+
end
|
|
1382
|
+
|
|
1383
|
+
# Parse options for diff command
|
|
1384
|
+
#
|
|
1385
|
+
# @return [void]
|
|
1386
|
+
#
|
|
1387
|
+
|
|
1388
|
+
def parse_diff_options
|
|
1389
|
+
OptionParser.new do |opts|
|
|
1390
|
+
opts.banner = "Usage: synthra diff <path1> <path2>"
|
|
1391
|
+
end.parse!(@args)
|
|
1392
|
+
end
|
|
1393
|
+
|
|
1394
|
+
# Parse options for repl command
|
|
1395
|
+
#
|
|
1396
|
+
# @return [void]
|
|
1397
|
+
#
|
|
1398
|
+
# :nocov:
|
|
1399
|
+
def parse_repl_options
|
|
1400
|
+
@options[:verbose] = false
|
|
1401
|
+
OptionParser.new do |opts|
|
|
1402
|
+
opts.banner = "Usage: synthra repl [options]"
|
|
1403
|
+
opts.on("-v", "--verbose", "Show verbose output including stack traces") do
|
|
1404
|
+
@options[:verbose] = true
|
|
1405
|
+
end
|
|
1406
|
+
end.parse!(@args)
|
|
1407
|
+
end
|
|
1408
|
+
# :nocov:
|
|
1409
|
+
|
|
1410
|
+
# Load schemas from directory without validation
|
|
1411
|
+
#
|
|
1412
|
+
# @param registry [Registry] registry to load schemas into
|
|
1413
|
+
# @param dir [String] directory path
|
|
1414
|
+
# @return [void]
|
|
1415
|
+
#
|
|
1416
|
+
|
|
1417
|
+
def load_schemas_from_dir_no_validate(registry, dir)
|
|
1418
|
+
dir = File.expand_path(dir)
|
|
1419
|
+
raise ArgumentError, "Invalid directory path: #{dir}" if dir.include?("..")
|
|
1420
|
+
raise Errno::ENOENT, "Directory not found: #{dir}" unless Dir.exist?(dir)
|
|
1421
|
+
|
|
1422
|
+
pattern = File.join(dir, "*.dsl")
|
|
1423
|
+
files = Dir.glob(pattern)
|
|
1424
|
+
|
|
1425
|
+
if files.empty?
|
|
1426
|
+
$stderr.puts "No .dsl files found in #{dir}"
|
|
1427
|
+
return
|
|
1428
|
+
end
|
|
1429
|
+
|
|
1430
|
+
files.each do |f|
|
|
1431
|
+
file_path = File.expand_path(f)
|
|
1432
|
+
raise ArgumentError, "Invalid file path: #{f}" unless file_path.start_with?(dir)
|
|
1433
|
+
registry.load_file(f, validate: false)
|
|
1434
|
+
end
|
|
1435
|
+
end
|
|
1436
|
+
|
|
1437
|
+
# Execute the generate command
|
|
1438
|
+
#
|
|
1439
|
+
# Loads schemas from a directory, finds the specified schema,
|
|
1440
|
+
# and generates fake data according to options.
|
|
1441
|
+
#
|
|
1442
|
+
# @return [Integer] exit code
|
|
1443
|
+
#
|
|
1444
|
+
|
|
1445
|
+
def generate_command
|
|
1446
|
+
parse_generate_options
|
|
1447
|
+
schema_name = @args.shift
|
|
1448
|
+
|
|
1449
|
+
unless schema_name
|
|
1450
|
+
$stderr.puts "Usage: synthra generate <SchemaName> [options]"
|
|
1451
|
+
return EXIT_PARSE_ERROR
|
|
1452
|
+
end
|
|
1453
|
+
|
|
1454
|
+
registry = Registry.new
|
|
1455
|
+
load_schemas_from_dir(registry, @options[:dir])
|
|
1456
|
+
|
|
1457
|
+
schema = registry.schema(schema_name)
|
|
1458
|
+
generate_output(schema)
|
|
1459
|
+
EXIT_SUCCESS
|
|
1460
|
+
rescue KeyError
|
|
1461
|
+
$stderr.puts "Unknown schema: #{schema_name}"
|
|
1462
|
+
$stderr.puts "Available schemas: #{registry.schemas.keys.join(", ")}"
|
|
1463
|
+
EXIT_PARSE_ERROR
|
|
1464
|
+
end
|
|
1465
|
+
|
|
1466
|
+
# Load all .dsl files from a directory
|
|
1467
|
+
#
|
|
1468
|
+
# @param registry [Registry] registry to load schemas into
|
|
1469
|
+
# @param dir [String] directory path
|
|
1470
|
+
# @return [void]
|
|
1471
|
+
#
|
|
1472
|
+
|
|
1473
|
+
def load_schemas_from_dir(registry, dir)
|
|
1474
|
+
|
|
1475
|
+
# Validate and sanitize directory path to prevent path traversal
|
|
1476
|
+
dir = File.expand_path(dir)
|
|
1477
|
+
|
|
1478
|
+
# Reject paths with .. components (shouldn't happen after expand_path, but double-check)
|
|
1479
|
+
# :nocov:
|
|
1480
|
+
if dir.include?("..")
|
|
1481
|
+
raise ArgumentError, "Invalid directory path: #{dir}"
|
|
1482
|
+
end
|
|
1483
|
+
# :nocov:
|
|
1484
|
+
|
|
1485
|
+
# Verify directory exists
|
|
1486
|
+
unless Dir.exist?(dir)
|
|
1487
|
+
raise Errno::ENOENT, "Directory not found: #{dir}"
|
|
1488
|
+
end
|
|
1489
|
+
|
|
1490
|
+
pattern = File.join(dir, "*.dsl")
|
|
1491
|
+
files = Dir.glob(pattern)
|
|
1492
|
+
|
|
1493
|
+
if files.empty?
|
|
1494
|
+
$stderr.puts "No .dsl files found in #{dir}"
|
|
1495
|
+
return
|
|
1496
|
+
end
|
|
1497
|
+
|
|
1498
|
+
# Validate all files are within the directory (prevent symlink attacks)
|
|
1499
|
+
files.each do |f|
|
|
1500
|
+
file_path = File.expand_path(f)
|
|
1501
|
+
# :nocov:
|
|
1502
|
+
unless file_path.start_with?(dir)
|
|
1503
|
+
raise ArgumentError, "Invalid file path: #{f}"
|
|
1504
|
+
end
|
|
1505
|
+
# :nocov:
|
|
1506
|
+
registry.load_file(f)
|
|
1507
|
+
end
|
|
1508
|
+
end
|
|
1509
|
+
|
|
1510
|
+
# Generate and output fake data
|
|
1511
|
+
#
|
|
1512
|
+
# Handles both JSON and NDJSON output formats.
|
|
1513
|
+
#
|
|
1514
|
+
# @param schema [Schema] schema to generate from
|
|
1515
|
+
# @return [void]
|
|
1516
|
+
#
|
|
1517
|
+
|
|
1518
|
+
def generate_output(schema)
|
|
1519
|
+
options = { seed: @options[:seed], mode: @options[:mode] }.compact
|
|
1520
|
+
|
|
1521
|
+
if @options[:format] == :ndjson
|
|
1522
|
+
|
|
1523
|
+
# NDJSON: One JSON object per line, streamed
|
|
1524
|
+
schema.generate_stream(count: @options[:count], **options).each do |record|
|
|
1525
|
+
puts JSON.generate(sanitize_for_json(record))
|
|
1526
|
+
end
|
|
1527
|
+
else
|
|
1528
|
+
|
|
1529
|
+
# JSON: Single array or object
|
|
1530
|
+
records = schema.generate_many(@options[:count], **options)
|
|
1531
|
+
output = @options[:count] == 1 ? records.first : records
|
|
1532
|
+
|
|
1533
|
+
if @options[:pretty]
|
|
1534
|
+
puts JSON.pretty_generate(sanitize_for_json(output))
|
|
1535
|
+
else
|
|
1536
|
+
puts JSON.generate(sanitize_for_json(output))
|
|
1537
|
+
end
|
|
1538
|
+
end
|
|
1539
|
+
end
|
|
1540
|
+
|
|
1541
|
+
# Sanitize values for JSON serialization
|
|
1542
|
+
# Converts Infinity/NaN to null for valid JSON output
|
|
1543
|
+
#
|
|
1544
|
+
# @param value [Object] value to sanitize
|
|
1545
|
+
# @return [Object] sanitized value
|
|
1546
|
+
#
|
|
1547
|
+
def sanitize_for_json(value)
|
|
1548
|
+
case value
|
|
1549
|
+
when Float
|
|
1550
|
+
if value.infinite? || value.nan?
|
|
1551
|
+
nil
|
|
1552
|
+
else
|
|
1553
|
+
value
|
|
1554
|
+
end
|
|
1555
|
+
when Hash
|
|
1556
|
+
value.transform_values { |v| sanitize_for_json(v) }
|
|
1557
|
+
when Array
|
|
1558
|
+
value.map { |v| sanitize_for_json(v) }
|
|
1559
|
+
else
|
|
1560
|
+
value
|
|
1561
|
+
end
|
|
1562
|
+
end
|
|
1563
|
+
|
|
1564
|
+
# Parse options for validate command
|
|
1565
|
+
#
|
|
1566
|
+
# @return [void]
|
|
1567
|
+
#
|
|
1568
|
+
|
|
1569
|
+
def parse_validate_options
|
|
1570
|
+
OptionParser.new do |opts|
|
|
1571
|
+
opts.banner = "Usage: synthra validate <path>"
|
|
1572
|
+
end.parse!(@args)
|
|
1573
|
+
end
|
|
1574
|
+
|
|
1575
|
+
# Parse options for lint command
|
|
1576
|
+
#
|
|
1577
|
+
# @return [void]
|
|
1578
|
+
#
|
|
1579
|
+
|
|
1580
|
+
def parse_lint_options
|
|
1581
|
+
@options[:verbose] = false
|
|
1582
|
+
@options[:strict] = false
|
|
1583
|
+
|
|
1584
|
+
OptionParser.new do |opts|
|
|
1585
|
+
opts.banner = "Usage: synthra lint <path|directory>"
|
|
1586
|
+
|
|
1587
|
+
opts.on("-v", "--verbose", "Show detailed output including schema names") do
|
|
1588
|
+
@options[:verbose] = true
|
|
1589
|
+
end
|
|
1590
|
+
|
|
1591
|
+
opts.on("--strict", "Treat warnings as errors (exit non-zero)") do
|
|
1592
|
+
@options[:strict] = true
|
|
1593
|
+
end
|
|
1594
|
+
end.parse!(@args)
|
|
1595
|
+
end
|
|
1596
|
+
|
|
1597
|
+
# Parse options for preview command
|
|
1598
|
+
#
|
|
1599
|
+
# @return [void]
|
|
1600
|
+
#
|
|
1601
|
+
|
|
1602
|
+
def parse_preview_options
|
|
1603
|
+
OptionParser.new do |opts|
|
|
1604
|
+
opts.banner = "Usage: synthra preview <SchemaName> [options]"
|
|
1605
|
+
|
|
1606
|
+
opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
|
|
1607
|
+
@options[:dir] = v
|
|
1608
|
+
end
|
|
1609
|
+
|
|
1610
|
+
opts.on("-s", "--seed SEED", Integer, "Seed for deterministic generation") do |v|
|
|
1611
|
+
@options[:seed] = v
|
|
1612
|
+
end
|
|
1613
|
+
|
|
1614
|
+
opts.on("-m", "--mode MODE", %i[random edge invalid mixed], "Generation mode") do |v|
|
|
1615
|
+
@options[:mode] = v
|
|
1616
|
+
end
|
|
1617
|
+
end.parse!(@args)
|
|
1618
|
+
end
|
|
1619
|
+
|
|
1620
|
+
# Parse options for generate command
|
|
1621
|
+
#
|
|
1622
|
+
# @return [void]
|
|
1623
|
+
#
|
|
1624
|
+
|
|
1625
|
+
def parse_generate_options
|
|
1626
|
+
OptionParser.new do |opts|
|
|
1627
|
+
opts.banner = "Usage: synthra generate <SchemaName> [options]"
|
|
1628
|
+
|
|
1629
|
+
opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
|
|
1630
|
+
@options[:dir] = v
|
|
1631
|
+
end
|
|
1632
|
+
|
|
1633
|
+
opts.on("-c", "--count COUNT", Integer, "Number of records to generate") do |v|
|
|
1634
|
+
@options[:count] = v
|
|
1635
|
+
end
|
|
1636
|
+
|
|
1637
|
+
opts.on("-s", "--seed SEED", Integer, "Seed for deterministic generation") do |v|
|
|
1638
|
+
@options[:seed] = v
|
|
1639
|
+
end
|
|
1640
|
+
|
|
1641
|
+
opts.on("-m", "--mode MODE", %i[random edge invalid mixed], "Generation mode") do |v|
|
|
1642
|
+
@options[:mode] = v
|
|
1643
|
+
end
|
|
1644
|
+
|
|
1645
|
+
opts.on("--json", "Output as JSON array (default)") do
|
|
1646
|
+
@options[:format] = :json
|
|
1647
|
+
end
|
|
1648
|
+
|
|
1649
|
+
opts.on("--ndjson", "Output as newline-delimited JSON") do
|
|
1650
|
+
@options[:format] = :ndjson
|
|
1651
|
+
end
|
|
1652
|
+
|
|
1653
|
+
opts.on("--pretty", "Pretty-print JSON output") do
|
|
1654
|
+
@options[:pretty] = true
|
|
1655
|
+
end
|
|
1656
|
+
end.parse!(@args)
|
|
1657
|
+
end
|
|
1658
|
+
|
|
1659
|
+
# Display help information
|
|
1660
|
+
#
|
|
1661
|
+
# @return [Integer] EXIT_SUCCESS
|
|
1662
|
+
#
|
|
1663
|
+
|
|
1664
|
+
def show_help
|
|
1665
|
+
puts <<~HELP
|
|
1666
|
+
Synthra - Generate fake data from a human-friendly DSL
|
|
1667
|
+
|
|
1668
|
+
Usage:
|
|
1669
|
+
synthra <command> [options]
|
|
1670
|
+
|
|
1671
|
+
Commands:
|
|
1672
|
+
validate <path> Validate a DSL schema file (syntax check)
|
|
1673
|
+
lint <path|dir> Lint schemas for semantic errors (copy paths, etc.)
|
|
1674
|
+
preview <SchemaName> Preview a single record (quick testing)
|
|
1675
|
+
generate <SchemaName> Generate fake data for a schema
|
|
1676
|
+
info <path|dir> Show schema information (version, fields, etc.)
|
|
1677
|
+
diff <path1> <path2> Compare two schema files/directories
|
|
1678
|
+
export <SchemaName> Export schema as JSON Schema or TypeScript
|
|
1679
|
+
graph <path|dir> Generate GraphViz visualization of schemas
|
|
1680
|
+
watch <directory> Watch directory for changes and validate
|
|
1681
|
+
repl Start interactive REPL for testing
|
|
1682
|
+
lsp Start Language Server Protocol server
|
|
1683
|
+
import <file> Import schemas from OpenAPI/Swagger
|
|
1684
|
+
docs <directory> Generate HTML documentation
|
|
1685
|
+
live <directory> Start live preview server with web UI
|
|
1686
|
+
seed Seed database with generated data
|
|
1687
|
+
server Start production API server
|
|
1688
|
+
contracts Manage data contracts registry
|
|
1689
|
+
perf High-performance generation mode
|
|
1690
|
+
|
|
1691
|
+
Lint Options:
|
|
1692
|
+
-v, --verbose Show detailed output including schema names
|
|
1693
|
+
--strict Treat warnings as errors (exit non-zero)
|
|
1694
|
+
|
|
1695
|
+
Preview Options:
|
|
1696
|
+
-d, --dir DIR Directory containing .dsl files (default: .)
|
|
1697
|
+
-s, --seed SEED Seed for deterministic generation
|
|
1698
|
+
-m, --mode MODE Generation mode: random, edge, invalid, mixed
|
|
1699
|
+
|
|
1700
|
+
Generate Options:
|
|
1701
|
+
-d, --dir DIR Directory containing .dsl files (default: .)
|
|
1702
|
+
-c, --count COUNT Number of records to generate (default: 1)
|
|
1703
|
+
-s, --seed SEED Seed for deterministic generation
|
|
1704
|
+
-m, --mode MODE Generation mode: random, edge, invalid, mixed
|
|
1705
|
+
--json Output as JSON array (default)
|
|
1706
|
+
--ndjson Output as newline-delimited JSON
|
|
1707
|
+
--pretty Pretty-print JSON output
|
|
1708
|
+
|
|
1709
|
+
Export Options:
|
|
1710
|
+
-d, --dir DIR Directory containing .dsl files
|
|
1711
|
+
-f, --format FORMAT Export format (see formats below)
|
|
1712
|
+
-o, --output FILE Output file (default: stdout)
|
|
1713
|
+
--out-dir DIR Output directory (auto-generates filename)
|
|
1714
|
+
--auto-file Auto-generate filename based on schema and format
|
|
1715
|
+
-c, --count COUNT Number of records for data exports (default: 10)
|
|
1716
|
+
-s, --seed SEED Seed for deterministic generation
|
|
1717
|
+
-a, --all Export all schemas (typescript/javascript/python/sql)
|
|
1718
|
+
--dialect DIALECT SQL dialect: postgresql, mysql, sqlite
|
|
1719
|
+
--style STYLE Python style: pydantic, dataclass, typed_dict
|
|
1720
|
+
--batch Use batch INSERT for sql-insert format
|
|
1721
|
+
--compact No pretty-printing for JSON output
|
|
1722
|
+
--envelope KEY Wrap JSON in envelope with this key
|
|
1723
|
+
--root NAME Root element name for XML
|
|
1724
|
+
--item NAME Item element name for XML
|
|
1725
|
+
|
|
1726
|
+
Schema Export Formats (types/structure):
|
|
1727
|
+
json-schema JSON Schema (draft 2020-12)
|
|
1728
|
+
typescript, ts TypeScript interfaces
|
|
1729
|
+
javascript, js JavaScript with JSDoc types
|
|
1730
|
+
python, py, pydantic Python Pydantic models
|
|
1731
|
+
dataclass Python dataclasses
|
|
1732
|
+
sql, sql-ddl SQL CREATE TABLE (use --dialect)
|
|
1733
|
+
graphviz, dot GraphViz DOT diagram
|
|
1734
|
+
protobuf, proto Protocol Buffers / gRPC
|
|
1735
|
+
openapi, swagger OpenAPI 3.0 specification
|
|
1736
|
+
|
|
1737
|
+
Data Export Formats (generated records):
|
|
1738
|
+
json, json-data JSON array of records
|
|
1739
|
+
csv CSV with headers
|
|
1740
|
+
sql-insert, insert SQL INSERT statements (use --dialect)
|
|
1741
|
+
yaml, yml YAML format
|
|
1742
|
+
xml XML format (use --root, --item)
|
|
1743
|
+
|
|
1744
|
+
Graph Options:
|
|
1745
|
+
-d, --dir DIR Directory containing .dsl files
|
|
1746
|
+
-o, --output FILE Output DOT file
|
|
1747
|
+
-r, --render Also render image (requires graphviz installed)
|
|
1748
|
+
--format FORMAT Render format: png, svg, pdf (default: png)
|
|
1749
|
+
--direction DIR Graph direction: LR, TB, BT, RL (default: LR)
|
|
1750
|
+
-g, --group Group schemas by category
|
|
1751
|
+
|
|
1752
|
+
Watch Options:
|
|
1753
|
+
-i, --interval SECONDS Check interval in seconds (default: 1)
|
|
1754
|
+
|
|
1755
|
+
Info Options:
|
|
1756
|
+
-v, --verbose Show field details
|
|
1757
|
+
|
|
1758
|
+
Server Options (Production API):
|
|
1759
|
+
-d, --dir DIR Directory containing .dsl files
|
|
1760
|
+
-p, --port PORT Server port (default: 3000)
|
|
1761
|
+
-h, --host HOST Server host (default: 0.0.0.0)
|
|
1762
|
+
-e, --env ENV Environment: development, production
|
|
1763
|
+
--no-cors Disable CORS headers
|
|
1764
|
+
--rate-limit LIMIT Rate limit per minute (0 = unlimited)
|
|
1765
|
+
--api-key KEY Require API key authentication
|
|
1766
|
+
|
|
1767
|
+
Contracts Options:
|
|
1768
|
+
-d, --dir DIR Contracts directory (default: contracts)
|
|
1769
|
+
-s, --schema-dir DIR Schema directory
|
|
1770
|
+
-v, --version VERSION Version (semver)
|
|
1771
|
+
-m, --message MSG Changelog message
|
|
1772
|
+
--sunset DATE Sunset date for deprecation
|
|
1773
|
+
|
|
1774
|
+
Performance Mode Options:
|
|
1775
|
+
-d, --dir DIR Directory containing .dsl files
|
|
1776
|
+
-c, --count COUNT Number of records (default: 100000)
|
|
1777
|
+
-o, --output FILE Output file (for file generation)
|
|
1778
|
+
-f, --format FORMAT Output format: ndjson, json, csv
|
|
1779
|
+
-s, --seed SEED Random seed for deterministic generation
|
|
1780
|
+
-b, --benchmark Run benchmark instead of generating
|
|
1781
|
+
|
|
1782
|
+
Examples:
|
|
1783
|
+
synthra validate schemas/user.dsl
|
|
1784
|
+
synthra lint schemas/
|
|
1785
|
+
synthra lint schemas/ --strict
|
|
1786
|
+
synthra preview User --dir schemas
|
|
1787
|
+
synthra generate User --dir schemas --count 10
|
|
1788
|
+
synthra generate User --dir schemas --count 1000 --ndjson
|
|
1789
|
+
|
|
1790
|
+
# Schema exports (types/structure)
|
|
1791
|
+
synthra export User -d schemas -f json-schema
|
|
1792
|
+
synthra export User -d schemas -f typescript -o user.ts
|
|
1793
|
+
synthra export User -d schemas -f javascript -o user.js
|
|
1794
|
+
synthra export User -d schemas -f python --style pydantic
|
|
1795
|
+
synthra export User -d schemas -f dataclass -o models.py
|
|
1796
|
+
synthra export User -d schemas -f sql --dialect mysql
|
|
1797
|
+
synthra export --all -d schemas -f typescript -o types.ts
|
|
1798
|
+
synthra export --all -d schemas -f python -o models.py
|
|
1799
|
+
|
|
1800
|
+
# Auto-generate files (--auto-file or --out-dir)
|
|
1801
|
+
synthra export User -d schemas -f typescript --auto-file
|
|
1802
|
+
synthra export User -d schemas -f python --out-dir ./generated
|
|
1803
|
+
synthra export User -d schemas -f csv -c 100 --out-dir ./data
|
|
1804
|
+
|
|
1805
|
+
# Data exports (generated records)
|
|
1806
|
+
synthra export User -d schemas -f json -c 100 -o users.json
|
|
1807
|
+
synthra export User -d schemas -f csv -c 1000 -o users.csv
|
|
1808
|
+
synthra export User -d schemas -f sql-insert -c 100 --dialect postgresql
|
|
1809
|
+
synthra export User -d schemas -f sql-insert -c 1000 --batch
|
|
1810
|
+
synthra export User -d schemas -f yaml -c 50 -o users.yaml
|
|
1811
|
+
synthra export User -d schemas -f xml -c 20 --root users --item user
|
|
1812
|
+
|
|
1813
|
+
# GraphViz visualization
|
|
1814
|
+
synthra graph schemas/ --output schema.dot --render
|
|
1815
|
+
synthra graph schemas/ -o schema.dot --render --format svg
|
|
1816
|
+
|
|
1817
|
+
# Other commands
|
|
1818
|
+
synthra watch schemas/
|
|
1819
|
+
synthra info schemas/ --verbose
|
|
1820
|
+
synthra diff schemas/v1/ schemas/v2/
|
|
1821
|
+
synthra repl
|
|
1822
|
+
synthra lsp
|
|
1823
|
+
|
|
1824
|
+
# Production API Server
|
|
1825
|
+
synthra server schemas/ --port 3000
|
|
1826
|
+
synthra server schemas/ --port 8080 --env production
|
|
1827
|
+
synthra server schemas/ --api-key secret123 --rate-limit 100
|
|
1828
|
+
|
|
1829
|
+
# Data Contracts Registry
|
|
1830
|
+
synthra contracts publish User -v 1.0.0 -s schemas/
|
|
1831
|
+
synthra contracts deprecate User -v 1.0.0 --sunset 2026-06-01
|
|
1832
|
+
synthra contracts list
|
|
1833
|
+
synthra contracts history User
|
|
1834
|
+
synthra contracts diff User 1.0.0 2.0.0
|
|
1835
|
+
|
|
1836
|
+
# High-Performance Mode (millions of records)
|
|
1837
|
+
synthra perf User -d schemas/ -c 1000000 -o users.ndjson
|
|
1838
|
+
synthra perf User -d schemas/ -c 100000 -f csv -o users.csv
|
|
1839
|
+
synthra perf User -d schemas/ --benchmark
|
|
1840
|
+
|
|
1841
|
+
# Protocol Buffers / gRPC export
|
|
1842
|
+
synthra export User -d schemas/ -f protobuf -o user.proto
|
|
1843
|
+
synthra export --all -d schemas/ -f protobuf -o all.proto
|
|
1844
|
+
|
|
1845
|
+
# OpenAPI export
|
|
1846
|
+
synthra export --all -d schemas/ -f openapi -o api.yaml
|
|
1847
|
+
|
|
1848
|
+
Exit Codes:
|
|
1849
|
+
0 Success
|
|
1850
|
+
1 Parse/validation error
|
|
1851
|
+
2 Runtime error
|
|
1852
|
+
HELP
|
|
1853
|
+
EXIT_SUCCESS
|
|
1854
|
+
end
|
|
1855
|
+
|
|
1856
|
+
# Execute the LSP server command
|
|
1857
|
+
#
|
|
1858
|
+
# Starts the Language Server Protocol server for IDE integration.
|
|
1859
|
+
# Provides features like go-to-definition, hover, completion, and diagnostics.
|
|
1860
|
+
#
|
|
1861
|
+
# @return [Integer] exit code (never returns normally - runs server loop)
|
|
1862
|
+
#
|
|
1863
|
+
# :nocov:
|
|
1864
|
+
def lsp_command
|
|
1865
|
+
require_relative "lsp/server"
|
|
1866
|
+
server = LSP::Server.new
|
|
1867
|
+
server.run
|
|
1868
|
+
EXIT_SUCCESS
|
|
1869
|
+
rescue Interrupt
|
|
1870
|
+
EXIT_SUCCESS
|
|
1871
|
+
end
|
|
1872
|
+
# :nocov:
|
|
1873
|
+
|
|
1874
|
+
# Execute the import command
|
|
1875
|
+
#
|
|
1876
|
+
# Imports schemas from external formats (OpenAPI, JSON Schema).
|
|
1877
|
+
#
|
|
1878
|
+
# @return [Integer] exit code
|
|
1879
|
+
#
|
|
1880
|
+
def import_command
|
|
1881
|
+
parse_import_options
|
|
1882
|
+
path = @args.shift
|
|
1883
|
+
|
|
1884
|
+
unless path
|
|
1885
|
+
$stderr.puts "Usage: synthra import <openapi.yaml|json-schema.json> [options]"
|
|
1886
|
+
return EXIT_PARSE_ERROR
|
|
1887
|
+
end
|
|
1888
|
+
|
|
1889
|
+
cmd = CLI::Commands::Import.new
|
|
1890
|
+
cmd.call(path, @options)
|
|
1891
|
+
end
|
|
1892
|
+
|
|
1893
|
+
# Execute the docs command
|
|
1894
|
+
#
|
|
1895
|
+
# Generates HTML documentation from schemas.
|
|
1896
|
+
#
|
|
1897
|
+
# @return [Integer] exit code
|
|
1898
|
+
#
|
|
1899
|
+
def docs_command
|
|
1900
|
+
parse_docs_options
|
|
1901
|
+
path = @args.shift || @options[:dir]
|
|
1902
|
+
|
|
1903
|
+
unless path && File.directory?(path)
|
|
1904
|
+
$stderr.puts "Usage: synthra docs <schema-directory> [options]"
|
|
1905
|
+
return EXIT_PARSE_ERROR
|
|
1906
|
+
end
|
|
1907
|
+
|
|
1908
|
+
cmd = CLI::Commands::Docs.new
|
|
1909
|
+
cmd.call(path, @options)
|
|
1910
|
+
end
|
|
1911
|
+
|
|
1912
|
+
# Execute the live command
|
|
1913
|
+
#
|
|
1914
|
+
# Starts a live preview server with web UI.
|
|
1915
|
+
#
|
|
1916
|
+
# @return [Integer] exit code
|
|
1917
|
+
#
|
|
1918
|
+
# :nocov:
|
|
1919
|
+
def live_command
|
|
1920
|
+
parse_live_options
|
|
1921
|
+
path = @args.shift || @options[:dir]
|
|
1922
|
+
|
|
1923
|
+
unless path && File.directory?(path)
|
|
1924
|
+
$stderr.puts "Usage: synthra live <schema-directory> [options]"
|
|
1925
|
+
return EXIT_PARSE_ERROR
|
|
1926
|
+
end
|
|
1927
|
+
|
|
1928
|
+
cmd = CLI::Commands::Live.new
|
|
1929
|
+
cmd.call(path, @options)
|
|
1930
|
+
end
|
|
1931
|
+
# :nocov:
|
|
1932
|
+
|
|
1933
|
+
# Execute the seed command
|
|
1934
|
+
#
|
|
1935
|
+
# Seeds databases with generated data.
|
|
1936
|
+
#
|
|
1937
|
+
# @return [Integer] exit code
|
|
1938
|
+
#
|
|
1939
|
+
def seed_command
|
|
1940
|
+
parse_seed_options
|
|
1941
|
+
cmd = CLI::Commands::Seed.new
|
|
1942
|
+
cmd.call(@options)
|
|
1943
|
+
end
|
|
1944
|
+
|
|
1945
|
+
# Parse options for import command
|
|
1946
|
+
#
|
|
1947
|
+
# @return [void]
|
|
1948
|
+
#
|
|
1949
|
+
def parse_import_options
|
|
1950
|
+
@options[:output] = "schemas"
|
|
1951
|
+
|
|
1952
|
+
OptionParser.new do |opts|
|
|
1953
|
+
opts.banner = "Usage: synthra import <file> [options]"
|
|
1954
|
+
|
|
1955
|
+
opts.on("-o", "--output DIR", "Output directory for generated schemas") do |v|
|
|
1956
|
+
@options[:output] = v
|
|
1957
|
+
end
|
|
1958
|
+
end.parse!(@args)
|
|
1959
|
+
end
|
|
1960
|
+
|
|
1961
|
+
# Parse options for docs command
|
|
1962
|
+
#
|
|
1963
|
+
# @return [void]
|
|
1964
|
+
#
|
|
1965
|
+
def parse_docs_options
|
|
1966
|
+
@options[:output] = "docs"
|
|
1967
|
+
@options[:title] = "Synthra Schema Documentation"
|
|
1968
|
+
|
|
1969
|
+
OptionParser.new do |opts|
|
|
1970
|
+
opts.banner = "Usage: synthra docs <directory> [options]"
|
|
1971
|
+
|
|
1972
|
+
opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
|
|
1973
|
+
@options[:dir] = v
|
|
1974
|
+
end
|
|
1975
|
+
|
|
1976
|
+
opts.on("-o", "--output DIR", "Output directory for documentation") do |v|
|
|
1977
|
+
@options[:output] = v
|
|
1978
|
+
end
|
|
1979
|
+
|
|
1980
|
+
opts.on("-t", "--title TITLE", "Documentation title") do |v|
|
|
1981
|
+
@options[:title] = v
|
|
1982
|
+
end
|
|
1983
|
+
end.parse!(@args)
|
|
1984
|
+
end
|
|
1985
|
+
|
|
1986
|
+
# Parse options for live command
|
|
1987
|
+
#
|
|
1988
|
+
# @return [void]
|
|
1989
|
+
#
|
|
1990
|
+
# :nocov:
|
|
1991
|
+
def parse_live_options
|
|
1992
|
+
@options[:port] = 4567
|
|
1993
|
+
|
|
1994
|
+
OptionParser.new do |opts|
|
|
1995
|
+
opts.banner = "Usage: synthra live <directory> [options]"
|
|
1996
|
+
|
|
1997
|
+
opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
|
|
1998
|
+
@options[:dir] = v
|
|
1999
|
+
end
|
|
2000
|
+
|
|
2001
|
+
opts.on("-p", "--port PORT", Integer, "Server port (default: 4567)") do |v|
|
|
2002
|
+
@options[:port] = v
|
|
2003
|
+
end
|
|
2004
|
+
end.parse!(@args)
|
|
2005
|
+
end
|
|
2006
|
+
# :nocov:
|
|
2007
|
+
|
|
2008
|
+
# Parse options for seed command
|
|
2009
|
+
#
|
|
2010
|
+
# @return [void]
|
|
2011
|
+
#
|
|
2012
|
+
def parse_seed_options
|
|
2013
|
+
@options[:schema_dir] = "schemas"
|
|
2014
|
+
@options[:count] = 10
|
|
2015
|
+
@options[:file] = nil
|
|
2016
|
+
|
|
2017
|
+
OptionParser.new do |opts|
|
|
2018
|
+
opts.banner = "Usage: synthra seed [options]"
|
|
2019
|
+
|
|
2020
|
+
opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
|
|
2021
|
+
@options[:schema_dir] = v
|
|
2022
|
+
end
|
|
2023
|
+
|
|
2024
|
+
opts.on("-c", "--count COUNT", Integer, "Records per schema (default: 10)") do |v|
|
|
2025
|
+
@options[:count] = v
|
|
2026
|
+
end
|
|
2027
|
+
|
|
2028
|
+
opts.on("-f", "--file FILE", "Seed file to execute") do |v|
|
|
2029
|
+
@options[:file] = v
|
|
2030
|
+
end
|
|
2031
|
+
end.parse!(@args)
|
|
2032
|
+
end
|
|
2033
|
+
|
|
2034
|
+
# Execute the server command
|
|
2035
|
+
#
|
|
2036
|
+
# Starts the production API server for data generation.
|
|
2037
|
+
#
|
|
2038
|
+
# @return [Integer] exit code
|
|
2039
|
+
#
|
|
2040
|
+
# :nocov:
|
|
2041
|
+
def server_command
|
|
2042
|
+
parse_server_options
|
|
2043
|
+
path = @args.shift || @options[:dir]
|
|
2044
|
+
|
|
2045
|
+
unless path && File.directory?(path)
|
|
2046
|
+
$stderr.puts "Usage: synthra server <schema-directory> [options]"
|
|
2047
|
+
return EXIT_PARSE_ERROR
|
|
2048
|
+
end
|
|
2049
|
+
|
|
2050
|
+
puts "Starting Synthra API Server..."
|
|
2051
|
+
Synthra::APIServer.start(
|
|
2052
|
+
schema_dir: path,
|
|
2053
|
+
port: @options[:port],
|
|
2054
|
+
host: @options[:host],
|
|
2055
|
+
environment: @options[:environment],
|
|
2056
|
+
cors: @options[:cors],
|
|
2057
|
+
rate_limit: @options[:rate_limit],
|
|
2058
|
+
auth: @options[:auth]
|
|
2059
|
+
)
|
|
2060
|
+
EXIT_SUCCESS
|
|
2061
|
+
rescue Interrupt
|
|
2062
|
+
puts "\nServer stopped."
|
|
2063
|
+
EXIT_SUCCESS
|
|
2064
|
+
end
|
|
2065
|
+
# :nocov:
|
|
2066
|
+
|
|
2067
|
+
# Execute the contracts command
|
|
2068
|
+
#
|
|
2069
|
+
# Manages data contracts registry.
|
|
2070
|
+
#
|
|
2071
|
+
# @return [Integer] exit code
|
|
2072
|
+
#
|
|
2073
|
+
def contracts_command
|
|
2074
|
+
parse_contracts_options
|
|
2075
|
+
subcommand = @args.shift
|
|
2076
|
+
|
|
2077
|
+
case subcommand
|
|
2078
|
+
when "publish"
|
|
2079
|
+
contracts_publish
|
|
2080
|
+
when "deprecate"
|
|
2081
|
+
contracts_deprecate
|
|
2082
|
+
when "list"
|
|
2083
|
+
contracts_list
|
|
2084
|
+
when "history"
|
|
2085
|
+
contracts_history
|
|
2086
|
+
when "diff"
|
|
2087
|
+
contracts_diff
|
|
2088
|
+
else
|
|
2089
|
+
puts "Usage: synthra contracts <subcommand> [options]"
|
|
2090
|
+
puts ""
|
|
2091
|
+
puts "Subcommands:"
|
|
2092
|
+
puts " publish <schema> -v VERSION Publish a schema version"
|
|
2093
|
+
puts " deprecate <schema> -v VERSION Deprecate a schema version"
|
|
2094
|
+
puts " list List all contracts"
|
|
2095
|
+
puts " history <schema> Show schema history"
|
|
2096
|
+
puts " diff <schema> <v1> <v2> Compare versions"
|
|
2097
|
+
EXIT_SUCCESS
|
|
2098
|
+
end
|
|
2099
|
+
end
|
|
2100
|
+
|
|
2101
|
+
# Execute the perf command
|
|
2102
|
+
#
|
|
2103
|
+
# High-performance generation for millions of records.
|
|
2104
|
+
#
|
|
2105
|
+
# @return [Integer] exit code
|
|
2106
|
+
#
|
|
2107
|
+
def perf_command
|
|
2108
|
+
parse_perf_options
|
|
2109
|
+
schema_name = @args.shift
|
|
2110
|
+
|
|
2111
|
+
unless schema_name
|
|
2112
|
+
$stderr.puts "Usage: synthra perf <SchemaName> [options]"
|
|
2113
|
+
return EXIT_PARSE_ERROR
|
|
2114
|
+
end
|
|
2115
|
+
|
|
2116
|
+
registry = load_registry(@options[:dir])
|
|
2117
|
+
unless registry.schema?(schema_name)
|
|
2118
|
+
$stderr.puts "Schema not found: #{schema_name}"
|
|
2119
|
+
return EXIT_PARSE_ERROR
|
|
2120
|
+
end
|
|
2121
|
+
|
|
2122
|
+
schema = registry.schema(schema_name)
|
|
2123
|
+
count = @options[:count]
|
|
2124
|
+
|
|
2125
|
+
if @options[:output]
|
|
2126
|
+
# Generate to file
|
|
2127
|
+
result = Synthra::PerformanceMode.to_file(
|
|
2128
|
+
schema,
|
|
2129
|
+
count: count,
|
|
2130
|
+
output: @options[:output],
|
|
2131
|
+
format: @options[:format],
|
|
2132
|
+
seed: @options[:seed],
|
|
2133
|
+
mode: @options[:mode]
|
|
2134
|
+
) do |progress|
|
|
2135
|
+
print "\r#{progress[:current]} / #{progress[:total]} (#{progress[:percent]}%) - #{progress[:rate]} rec/s - ETA: #{progress[:eta]}s"
|
|
2136
|
+
end
|
|
2137
|
+
puts "\n\nGenerated #{result[:records]} records in #{result[:elapsed].round(2)}s (#{result[:rate].round(0)} rec/s)"
|
|
2138
|
+
elsif @options[:benchmark]
|
|
2139
|
+
# Benchmark mode
|
|
2140
|
+
puts "Benchmarking #{schema_name}..."
|
|
2141
|
+
results = Synthra::PerformanceMode.benchmark(schema, counts: [1000, 10_000, 100_000])
|
|
2142
|
+
results.each do |c, r|
|
|
2143
|
+
puts " #{c.to_s.rjust(7)} records: #{r[:elapsed]}s (#{r[:rate]} rec/s, #{r[:memory_mb]} MB)"
|
|
2144
|
+
end
|
|
2145
|
+
else
|
|
2146
|
+
# Stream to stdout
|
|
2147
|
+
Synthra::PerformanceMode.stream(schema, count: count, seed: @options[:seed]).each do |record|
|
|
2148
|
+
puts JSON.generate(record)
|
|
2149
|
+
end
|
|
2150
|
+
end
|
|
2151
|
+
|
|
2152
|
+
EXIT_SUCCESS
|
|
2153
|
+
end
|
|
2154
|
+
|
|
2155
|
+
# Parse options for server command
|
|
2156
|
+
#
|
|
2157
|
+
# @return [void]
|
|
2158
|
+
#
|
|
2159
|
+
# :nocov:
|
|
2160
|
+
def parse_server_options
|
|
2161
|
+
@options[:port] = 3000
|
|
2162
|
+
@options[:host] = "0.0.0.0"
|
|
2163
|
+
@options[:environment] = :development
|
|
2164
|
+
@options[:cors] = true
|
|
2165
|
+
@options[:rate_limit] = 0
|
|
2166
|
+
@options[:auth] = nil
|
|
2167
|
+
|
|
2168
|
+
OptionParser.new do |opts|
|
|
2169
|
+
opts.banner = "Usage: synthra server <directory> [options]"
|
|
2170
|
+
|
|
2171
|
+
opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
|
|
2172
|
+
@options[:dir] = v
|
|
2173
|
+
end
|
|
2174
|
+
|
|
2175
|
+
opts.on("-p", "--port PORT", Integer, "Server port (default: 3000)") do |v|
|
|
2176
|
+
@options[:port] = v
|
|
2177
|
+
end
|
|
2178
|
+
|
|
2179
|
+
opts.on("-h", "--host HOST", "Server host (default: 0.0.0.0)") do |v|
|
|
2180
|
+
@options[:host] = v
|
|
2181
|
+
end
|
|
2182
|
+
|
|
2183
|
+
opts.on("-e", "--env ENV", "Environment: development, production") do |v|
|
|
2184
|
+
@options[:environment] = v.to_sym
|
|
2185
|
+
end
|
|
2186
|
+
|
|
2187
|
+
opts.on("--no-cors", "Disable CORS headers") do
|
|
2188
|
+
@options[:cors] = false
|
|
2189
|
+
end
|
|
2190
|
+
|
|
2191
|
+
opts.on("--rate-limit LIMIT", Integer, "Rate limit per minute (0 = unlimited)") do |v|
|
|
2192
|
+
@options[:rate_limit] = v
|
|
2193
|
+
end
|
|
2194
|
+
|
|
2195
|
+
opts.on("--api-key KEY", "Require API key authentication") do |v|
|
|
2196
|
+
@options[:auth] = { type: :api_key, keys: [v] }
|
|
2197
|
+
end
|
|
2198
|
+
end.parse!(@args)
|
|
2199
|
+
end
|
|
2200
|
+
# :nocov:
|
|
2201
|
+
|
|
2202
|
+
# Parse options for contracts command
|
|
2203
|
+
#
|
|
2204
|
+
# @return [void]
|
|
2205
|
+
#
|
|
2206
|
+
def parse_contracts_options
|
|
2207
|
+
@options[:contracts_dir] = "contracts"
|
|
2208
|
+
@options[:version] = nil
|
|
2209
|
+
@options[:changelog] = nil
|
|
2210
|
+
|
|
2211
|
+
OptionParser.new do |opts|
|
|
2212
|
+
opts.banner = "Usage: synthra contracts <subcommand> [options]"
|
|
2213
|
+
|
|
2214
|
+
opts.on("-d", "--dir DIR", "Contracts directory (default: contracts)") do |v|
|
|
2215
|
+
@options[:contracts_dir] = v
|
|
2216
|
+
end
|
|
2217
|
+
|
|
2218
|
+
opts.on("-s", "--schema-dir DIR", "Schema directory") do |v|
|
|
2219
|
+
@options[:schema_dir] = v
|
|
2220
|
+
end
|
|
2221
|
+
|
|
2222
|
+
opts.on("-v", "--version VERSION", "Version (semver)") do |v|
|
|
2223
|
+
@options[:version] = v
|
|
2224
|
+
end
|
|
2225
|
+
|
|
2226
|
+
opts.on("-m", "--message MSG", "Changelog message") do |v|
|
|
2227
|
+
@options[:changelog] = v
|
|
2228
|
+
end
|
|
2229
|
+
|
|
2230
|
+
opts.on("--sunset DATE", "Sunset date for deprecation") do |v|
|
|
2231
|
+
@options[:sunset] = Date.parse(v)
|
|
2232
|
+
end
|
|
2233
|
+
end.parse!(@args)
|
|
2234
|
+
end
|
|
2235
|
+
|
|
2236
|
+
# Parse options for perf command
|
|
2237
|
+
#
|
|
2238
|
+
# @return [void]
|
|
2239
|
+
#
|
|
2240
|
+
def parse_perf_options
|
|
2241
|
+
@options[:count] = 100_000
|
|
2242
|
+
@options[:format] = :ndjson
|
|
2243
|
+
@options[:benchmark] = false
|
|
2244
|
+
|
|
2245
|
+
OptionParser.new do |opts|
|
|
2246
|
+
opts.banner = "Usage: synthra perf <SchemaName> [options]"
|
|
2247
|
+
|
|
2248
|
+
opts.on("-d", "--dir DIR", "Directory containing .dsl files") do |v|
|
|
2249
|
+
@options[:dir] = v
|
|
2250
|
+
end
|
|
2251
|
+
|
|
2252
|
+
opts.on("-c", "--count COUNT", Integer, "Number of records (default: 100000)") do |v|
|
|
2253
|
+
@options[:count] = v
|
|
2254
|
+
end
|
|
2255
|
+
|
|
2256
|
+
opts.on("-o", "--output FILE", "Output file") do |v|
|
|
2257
|
+
@options[:output] = v
|
|
2258
|
+
end
|
|
2259
|
+
|
|
2260
|
+
opts.on("-f", "--format FORMAT", "Output format: ndjson, json, csv (default: ndjson)") do |v|
|
|
2261
|
+
@options[:format] = v.to_sym
|
|
2262
|
+
end
|
|
2263
|
+
|
|
2264
|
+
opts.on("-s", "--seed SEED", Integer, "Random seed") do |v|
|
|
2265
|
+
@options[:seed] = v
|
|
2266
|
+
end
|
|
2267
|
+
|
|
2268
|
+
opts.on("-m", "--mode MODE", "Generation mode") do |v|
|
|
2269
|
+
@options[:mode] = v.to_sym
|
|
2270
|
+
end
|
|
2271
|
+
|
|
2272
|
+
opts.on("-b", "--benchmark", "Run benchmark instead of generating") do
|
|
2273
|
+
@options[:benchmark] = true
|
|
2274
|
+
end
|
|
2275
|
+
end.parse!(@args)
|
|
2276
|
+
end
|
|
2277
|
+
|
|
2278
|
+
# Contracts subcommand: publish
|
|
2279
|
+
#
|
|
2280
|
+
# @return [Integer] exit code
|
|
2281
|
+
#
|
|
2282
|
+
def contracts_publish
|
|
2283
|
+
schema_name = @args.shift
|
|
2284
|
+
version = @options[:version]
|
|
2285
|
+
|
|
2286
|
+
unless schema_name && version
|
|
2287
|
+
$stderr.puts "Usage: synthra contracts publish <SchemaName> -v <version>"
|
|
2288
|
+
return EXIT_PARSE_ERROR
|
|
2289
|
+
end
|
|
2290
|
+
|
|
2291
|
+
registry = load_registry(@options[:schema_dir] || "schemas")
|
|
2292
|
+
unless registry.schema?(schema_name)
|
|
2293
|
+
$stderr.puts "Schema not found: #{schema_name}"
|
|
2294
|
+
return EXIT_PARSE_ERROR
|
|
2295
|
+
end
|
|
2296
|
+
|
|
2297
|
+
contracts = ContractsRegistry.new(@options[:contracts_dir])
|
|
2298
|
+
contract = contracts.publish(
|
|
2299
|
+
schema_name,
|
|
2300
|
+
version: version,
|
|
2301
|
+
schema: registry.schema(schema_name),
|
|
2302
|
+
changelog: @options[:changelog]
|
|
2303
|
+
)
|
|
2304
|
+
|
|
2305
|
+
puts "Published #{schema_name}@#{version}"
|
|
2306
|
+
puts " Hash: #{contract[:schema_hash]}"
|
|
2307
|
+
puts " Fields: #{contract[:fields].count}"
|
|
2308
|
+
EXIT_SUCCESS
|
|
2309
|
+
end
|
|
2310
|
+
|
|
2311
|
+
# Contracts subcommand: deprecate
|
|
2312
|
+
#
|
|
2313
|
+
# @return [Integer] exit code
|
|
2314
|
+
#
|
|
2315
|
+
def contracts_deprecate
|
|
2316
|
+
schema_name = @args.shift
|
|
2317
|
+
version = @options[:version]
|
|
2318
|
+
|
|
2319
|
+
unless schema_name && version
|
|
2320
|
+
$stderr.puts "Usage: synthra contracts deprecate <SchemaName> -v <version>"
|
|
2321
|
+
return EXIT_PARSE_ERROR
|
|
2322
|
+
end
|
|
2323
|
+
|
|
2324
|
+
contracts = ContractsRegistry.new(@options[:contracts_dir])
|
|
2325
|
+
contracts.deprecate(
|
|
2326
|
+
schema_name,
|
|
2327
|
+
version: version,
|
|
2328
|
+
sunset_date: @options[:sunset],
|
|
2329
|
+
message: @options[:changelog]
|
|
2330
|
+
)
|
|
2331
|
+
|
|
2332
|
+
puts "Deprecated #{schema_name}@#{version}"
|
|
2333
|
+
puts " Sunset: #{@options[:sunset]}" if @options[:sunset]
|
|
2334
|
+
EXIT_SUCCESS
|
|
2335
|
+
end
|
|
2336
|
+
|
|
2337
|
+
# Contracts subcommand: list
|
|
2338
|
+
#
|
|
2339
|
+
# @return [Integer] exit code
|
|
2340
|
+
#
|
|
2341
|
+
def contracts_list
|
|
2342
|
+
contracts = ContractsRegistry.new(@options[:contracts_dir])
|
|
2343
|
+
all = contracts.list
|
|
2344
|
+
|
|
2345
|
+
if all.empty?
|
|
2346
|
+
puts "No contracts found."
|
|
2347
|
+
return EXIT_SUCCESS
|
|
2348
|
+
end
|
|
2349
|
+
|
|
2350
|
+
puts "Data Contracts:"
|
|
2351
|
+
puts ""
|
|
2352
|
+
|
|
2353
|
+
contracts.schema_names.each do |name|
|
|
2354
|
+
puts " #{name}:"
|
|
2355
|
+
contracts.versions_for(name).each do |v|
|
|
2356
|
+
contract = contracts.get(name, version: v)
|
|
2357
|
+
state_icon = case contract[:state]
|
|
2358
|
+
when :published then "✅"
|
|
2359
|
+
when :deprecated then "⚠️"
|
|
2360
|
+
when :retired then "❌"
|
|
2361
|
+
else "📝"
|
|
2362
|
+
end
|
|
2363
|
+
puts " #{state_icon} v#{v} (#{contract[:fields].count} fields)"
|
|
2364
|
+
end
|
|
2365
|
+
end
|
|
2366
|
+
|
|
2367
|
+
EXIT_SUCCESS
|
|
2368
|
+
end
|
|
2369
|
+
|
|
2370
|
+
# Contracts subcommand: history
|
|
2371
|
+
#
|
|
2372
|
+
# @return [Integer] exit code
|
|
2373
|
+
#
|
|
2374
|
+
def contracts_history
|
|
2375
|
+
schema_name = @args.shift
|
|
2376
|
+
|
|
2377
|
+
unless schema_name
|
|
2378
|
+
$stderr.puts "Usage: synthra contracts history <SchemaName>"
|
|
2379
|
+
return EXIT_PARSE_ERROR
|
|
2380
|
+
end
|
|
2381
|
+
|
|
2382
|
+
contracts = ContractsRegistry.new(@options[:contracts_dir])
|
|
2383
|
+
history = contracts.history(schema_name)
|
|
2384
|
+
|
|
2385
|
+
if history.empty?
|
|
2386
|
+
puts "No history found for #{schema_name}"
|
|
2387
|
+
return EXIT_SUCCESS
|
|
2388
|
+
end
|
|
2389
|
+
|
|
2390
|
+
puts "History for #{schema_name}:"
|
|
2391
|
+
puts ""
|
|
2392
|
+
|
|
2393
|
+
history.reverse.each do |entry|
|
|
2394
|
+
puts " #{entry[:timestamp]} - v#{entry[:version]} #{entry[:action]}"
|
|
2395
|
+
puts " #{entry[:message]}" if entry[:message]
|
|
2396
|
+
end
|
|
2397
|
+
|
|
2398
|
+
EXIT_SUCCESS
|
|
2399
|
+
end
|
|
2400
|
+
|
|
2401
|
+
# Contracts subcommand: diff
|
|
2402
|
+
#
|
|
2403
|
+
# @return [Integer] exit code
|
|
2404
|
+
#
|
|
2405
|
+
def contracts_diff
|
|
2406
|
+
schema_name = @args.shift
|
|
2407
|
+
v1 = @args.shift
|
|
2408
|
+
v2 = @args.shift
|
|
2409
|
+
|
|
2410
|
+
unless schema_name && v1 && v2
|
|
2411
|
+
$stderr.puts "Usage: synthra contracts diff <SchemaName> <v1> <v2>"
|
|
2412
|
+
return EXIT_PARSE_ERROR
|
|
2413
|
+
end
|
|
2414
|
+
|
|
2415
|
+
contracts = ContractsRegistry.new(@options[:contracts_dir])
|
|
2416
|
+
result = contracts.compatible?(schema_name, v1, v2)
|
|
2417
|
+
|
|
2418
|
+
puts "Comparing #{schema_name} v#{v1} → v#{v2}"
|
|
2419
|
+
puts ""
|
|
2420
|
+
|
|
2421
|
+
if result[:compatible]
|
|
2422
|
+
puts "✅ Compatible (no breaking changes)"
|
|
2423
|
+
else
|
|
2424
|
+
puts "❌ Breaking changes detected!"
|
|
2425
|
+
end
|
|
2426
|
+
|
|
2427
|
+
puts ""
|
|
2428
|
+
puts "Changes:"
|
|
2429
|
+
changes = result[:changes]
|
|
2430
|
+
|
|
2431
|
+
if changes[:removed_fields].any?
|
|
2432
|
+
puts " Removed: #{changes[:removed_fields].join(', ')}"
|
|
2433
|
+
end
|
|
2434
|
+
|
|
2435
|
+
if changes[:added_fields].any?
|
|
2436
|
+
puts " Added: #{changes[:added_fields].join(', ')}"
|
|
2437
|
+
end
|
|
2438
|
+
|
|
2439
|
+
changes[:type_changes].each do |c|
|
|
2440
|
+
puts " Type changed: #{c[:field]} (#{c[:from]} → #{c[:to]})"
|
|
2441
|
+
end
|
|
2442
|
+
|
|
2443
|
+
changes[:required_changes].each do |c|
|
|
2444
|
+
puts " Required changed: #{c[:field]} (#{c[:change]})"
|
|
2445
|
+
end
|
|
2446
|
+
|
|
2447
|
+
result[:compatible] ? EXIT_SUCCESS : EXIT_PARSE_ERROR
|
|
2448
|
+
end
|
|
2449
|
+
|
|
2450
|
+
# Load registry from directory
|
|
2451
|
+
#
|
|
2452
|
+
# @param dir [String] directory path
|
|
2453
|
+
# @return [Registry] loaded registry
|
|
2454
|
+
#
|
|
2455
|
+
def load_registry(dir)
|
|
2456
|
+
registry = Registry.new
|
|
2457
|
+
registry.load_dir(dir || ".")
|
|
2458
|
+
registry
|
|
2459
|
+
end
|
|
2460
|
+
|
|
2461
|
+
# Display version information
|
|
2462
|
+
#
|
|
2463
|
+
# @return [Integer] EXIT_SUCCESS
|
|
2464
|
+
#
|
|
2465
|
+
|
|
2466
|
+
def show_version
|
|
2467
|
+
puts "Synthra version #{Synthra::VERSION}"
|
|
2468
|
+
EXIT_SUCCESS
|
|
2469
|
+
end
|
|
2470
|
+
end
|
|
2471
|
+
end
|