suma 0.1.23 → 0.1.25
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 +4 -4
- data/.rubocop.yml +1 -1
- data/.rubocop_todo.yml +53 -28
- data/Gemfile +1 -0
- data/README.adoc +340 -44
- data/lib/suma/cli/compare.rb +210 -0
- data/lib/suma/cli/export.rb +49 -37
- data/lib/suma/cli.rb +29 -8
- data/lib/suma/collection_manifest.rb +13 -0
- data/lib/suma/eengine/errors.rb +27 -0
- data/lib/suma/eengine/wrapper.rb +150 -0
- data/lib/suma/eengine_converter.rb +32 -0
- data/lib/suma/export_standalone_schema.rb +14 -0
- data/lib/suma/express_schema.rb +23 -6
- data/lib/suma/processor.rb +20 -1
- data/lib/suma/schema_collection.rb +6 -4
- data/lib/suma/schema_exporter.rb +23 -7
- data/lib/suma/version.rb +1 -1
- metadata +7 -2
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require_relative "../eengine/wrapper"
|
|
5
|
+
require_relative "../eengine_converter"
|
|
6
|
+
|
|
7
|
+
module Suma
|
|
8
|
+
module Cli
|
|
9
|
+
# Command to compare EXPRESS schemas using eengine
|
|
10
|
+
class Compare < Thor
|
|
11
|
+
desc "compare TRIAL_SCHEMA REFERENCE_SCHEMA",
|
|
12
|
+
"Compare EXPRESS schemas using eengine and generate Change YAML"
|
|
13
|
+
long_desc <<~DESC
|
|
14
|
+
Compare two EXPRESS schemas from different git branches/checkouts.
|
|
15
|
+
|
|
16
|
+
Typical workflow:
|
|
17
|
+
1. Check out old version of repo at /path/to/repo-old
|
|
18
|
+
2. Check out new version of repo at /path/to/repo-new
|
|
19
|
+
3. Run comparison:
|
|
20
|
+
suma compare \\
|
|
21
|
+
/path/to/repo-new/schemas/.../schema.exp \\
|
|
22
|
+
/path/to/repo-old/schemas/.../schema.exp \\
|
|
23
|
+
--version 2
|
|
24
|
+
|
|
25
|
+
The command will:
|
|
26
|
+
- Auto-detect repository roots from schema paths
|
|
27
|
+
- Use those as stepmod paths for eengine
|
|
28
|
+
- Generate/update the .changes.yaml file in the new repo
|
|
29
|
+
DESC
|
|
30
|
+
|
|
31
|
+
option :output, type: :string, aliases: "-o",
|
|
32
|
+
desc: "Output Change YAML file path " \
|
|
33
|
+
"(default: {schema}.changes.yaml in trial schema directory)"
|
|
34
|
+
option :version, type: :string, aliases: "-v", required: true,
|
|
35
|
+
desc: "Version number for this change edition"
|
|
36
|
+
option :mode, type: :string, default: "resource",
|
|
37
|
+
enum: ["resource", "module"],
|
|
38
|
+
desc: "Schema comparison mode"
|
|
39
|
+
option :trial_stepmod, type: :string,
|
|
40
|
+
desc: "Override auto-detected trial repo root"
|
|
41
|
+
option :reference_stepmod, type: :string,
|
|
42
|
+
desc: "Override auto-detected reference repo root"
|
|
43
|
+
option :verbose, type: :boolean, default: false,
|
|
44
|
+
desc: "Enable verbose output"
|
|
45
|
+
|
|
46
|
+
def compare(trial_schema, reference_schema)
|
|
47
|
+
# Validate schema files exist
|
|
48
|
+
unless File.exist?(trial_schema)
|
|
49
|
+
say "Error: Trial schema not found: #{trial_schema}", :red
|
|
50
|
+
exit 1
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
unless File.exist?(reference_schema)
|
|
54
|
+
say "Error: Reference schema not found: #{reference_schema}", :red
|
|
55
|
+
exit 1
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Check eengine availability
|
|
59
|
+
unless Eengine::Wrapper.available?
|
|
60
|
+
say "Error: eengine not found in PATH", :red
|
|
61
|
+
say "Install eengine following instructions at:"
|
|
62
|
+
say " macOS: https://github.com/expresslang/homebrew-eengine"
|
|
63
|
+
say " Linux: https://github.com/expresslang/eengine-releases"
|
|
64
|
+
exit 1
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Auto-detect repo roots
|
|
68
|
+
trial_stepmod = options[:trial_stepmod] ||
|
|
69
|
+
detect_repo_root(trial_schema)
|
|
70
|
+
reference_stepmod = options[:reference_stepmod] ||
|
|
71
|
+
detect_repo_root(reference_schema)
|
|
72
|
+
|
|
73
|
+
if options[:verbose]
|
|
74
|
+
say "Using eengine version: #{Eengine::Wrapper.version}", :green
|
|
75
|
+
say "Trial repo root: #{trial_stepmod}", :cyan
|
|
76
|
+
say "Reference repo root: #{reference_stepmod}", :cyan
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Create a temporary directory for eengine output
|
|
80
|
+
require "tmpdir"
|
|
81
|
+
out_dir = nil
|
|
82
|
+
out_dir = Dir.mktmpdir("eengine-compare-")
|
|
83
|
+
|
|
84
|
+
# Run comparison
|
|
85
|
+
result = Eengine::Wrapper.compare(
|
|
86
|
+
trial_schema,
|
|
87
|
+
reference_schema,
|
|
88
|
+
mode: options[:mode],
|
|
89
|
+
trial_stepmod: trial_stepmod,
|
|
90
|
+
reference_stepmod: reference_stepmod,
|
|
91
|
+
out_dir: out_dir,
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
unless result[:has_changes]
|
|
95
|
+
say "No changes detected between schemas", :yellow
|
|
96
|
+
# Clean up temp directory
|
|
97
|
+
FileUtils.rm_rf(out_dir) if out_dir && File.directory?(out_dir)
|
|
98
|
+
return
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
unless result[:xml_path]
|
|
102
|
+
say "Error: XML output not found", :red
|
|
103
|
+
exit 1
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
if options[:verbose]
|
|
107
|
+
say "Comparison XML generated: #{result[:xml_path]}", :green
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Convert to Change YAML
|
|
111
|
+
convert_to_change_yaml(result[:xml_path], trial_schema, out_dir)
|
|
112
|
+
rescue Eengine::EengineError => e
|
|
113
|
+
# Clean up temp directory
|
|
114
|
+
FileUtils.rm_rf(out_dir) if out_dir && File.directory?(out_dir)
|
|
115
|
+
say "Error: #{e.message}", :red
|
|
116
|
+
say e.stderr if e.respond_to?(:stderr) && options[:verbose]
|
|
117
|
+
exit 1
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
private
|
|
121
|
+
|
|
122
|
+
def detect_repo_root(schema_path)
|
|
123
|
+
# Walk up from schema path to find .git directory
|
|
124
|
+
current = File.expand_path(File.dirname(schema_path))
|
|
125
|
+
|
|
126
|
+
loop do
|
|
127
|
+
if File.directory?(File.join(current, ".git"))
|
|
128
|
+
return current
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
parent = File.dirname(current)
|
|
132
|
+
break if parent == current # reached root
|
|
133
|
+
|
|
134
|
+
current = parent
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# If no .git found, use the directory containing the schema
|
|
138
|
+
# (for non-git workflows)
|
|
139
|
+
File.dirname(schema_path)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def convert_to_change_yaml(xml_path, trial_schema, out_dir)
|
|
143
|
+
schema_name = extract_schema_name(trial_schema)
|
|
144
|
+
output_path = determine_output_path(trial_schema)
|
|
145
|
+
|
|
146
|
+
# Load existing ChangeSchema if it exists
|
|
147
|
+
existing_schema = if File.exist?(output_path)
|
|
148
|
+
if options[:verbose]
|
|
149
|
+
say "Loading existing change schema: " \
|
|
150
|
+
"#{output_path}", :cyan
|
|
151
|
+
end
|
|
152
|
+
require "expressir/changes"
|
|
153
|
+
Expressir::Changes::SchemaChange.from_file(output_path)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Convert using Suma's converter
|
|
157
|
+
converter = EengineConverter.new(xml_path, schema_name)
|
|
158
|
+
change_schema = converter.convert(
|
|
159
|
+
version: options[:version],
|
|
160
|
+
existing_change_schema: existing_schema,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
# Save using Expressir model
|
|
164
|
+
change_schema.to_file(output_path)
|
|
165
|
+
|
|
166
|
+
# Determine what action was taken
|
|
167
|
+
if existing_schema
|
|
168
|
+
existing_edition = existing_schema.editions.find do |ed|
|
|
169
|
+
ed.version == options[:version]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
say "Change YAML file updated: #{output_path}", :green
|
|
173
|
+
if existing_edition
|
|
174
|
+
say " Replaced existing version #{options[:version]}", :green
|
|
175
|
+
else
|
|
176
|
+
say " Added version #{options[:version]} to change editions",
|
|
177
|
+
:green
|
|
178
|
+
end
|
|
179
|
+
else
|
|
180
|
+
say "Change YAML file created: #{output_path}", :green
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
if options[:verbose]
|
|
184
|
+
say "\nGenerated change schema content:", :cyan
|
|
185
|
+
say File.read(output_path)
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Clean up temp directory and XML file
|
|
189
|
+
FileUtils.rm_rf(out_dir) if out_dir && File.directory?(out_dir)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def extract_schema_name(path)
|
|
193
|
+
# Remove version suffix if present (e.g., schema_1.exp -> schema)
|
|
194
|
+
basename = File.basename(path, ".exp")
|
|
195
|
+
basename.sub(/_\d+$/, "")
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def determine_output_path(trial_schema)
|
|
199
|
+
if options[:output]
|
|
200
|
+
options[:output]
|
|
201
|
+
else
|
|
202
|
+
# Place .changes.yaml next to the trial schema in the NEW repo
|
|
203
|
+
base = extract_schema_name(trial_schema)
|
|
204
|
+
dir = File.dirname(trial_schema)
|
|
205
|
+
File.join(dir, "#{base}.changes.yaml")
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
data/lib/suma/cli/export.rb
CHANGED
|
@@ -2,42 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
require "thor"
|
|
4
4
|
require_relative "../thor_ext"
|
|
5
|
+
require_relative "../export_standalone_schema"
|
|
5
6
|
|
|
6
7
|
module Suma
|
|
7
8
|
module Cli
|
|
8
9
|
# Export command for exporting EXPRESS schemas from a manifest
|
|
9
10
|
class Export < Thor
|
|
10
|
-
desc "export
|
|
11
|
-
"Export EXPRESS schemas from manifest"
|
|
11
|
+
desc "export *FILES",
|
|
12
|
+
"Export EXPRESS schemas from manifest files or " \
|
|
13
|
+
"standalone EXPRESS files"
|
|
12
14
|
option :output, type: :string, aliases: "-o", required: true,
|
|
13
15
|
desc: "Output directory path"
|
|
14
|
-
option :additional, type: :array, aliases: "-a",
|
|
15
|
-
desc: "Additional schemas manifest files to merge (can be specified multiple times)"
|
|
16
16
|
option :annotations, type: :boolean, default: false,
|
|
17
17
|
desc: "Include annotations (remarks/comments)"
|
|
18
18
|
option :zip, type: :boolean, default: false,
|
|
19
19
|
desc: "Create ZIP archive of exported schemas"
|
|
20
20
|
|
|
21
|
-
def export(
|
|
21
|
+
def export(*files)
|
|
22
22
|
require_relative "../schema_exporter"
|
|
23
23
|
require_relative "../utils"
|
|
24
24
|
require "expressir"
|
|
25
25
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"`#{manifest_file}` not found."
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
run(manifest_file, options)
|
|
26
|
+
validate_files(files)
|
|
27
|
+
run(files, options)
|
|
32
28
|
end
|
|
33
29
|
|
|
34
30
|
private
|
|
35
31
|
|
|
36
|
-
def
|
|
37
|
-
|
|
32
|
+
def validate_files(files)
|
|
33
|
+
if files.empty?
|
|
34
|
+
raise ArgumentError, "At least one file must be specified"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
files.each do |file|
|
|
38
|
+
unless File.exist?(file)
|
|
39
|
+
raise Errno::ENOENT, "Specified file `#{file}` not found."
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def run(files, options)
|
|
45
|
+
schemas = load_schemas_from_files(files)
|
|
38
46
|
|
|
39
47
|
exporter = SchemaExporter.new(
|
|
40
|
-
|
|
48
|
+
schemas: schemas,
|
|
41
49
|
output_path: options[:output],
|
|
42
50
|
options: {
|
|
43
51
|
annotations: options[:annotations],
|
|
@@ -48,36 +56,40 @@ module Suma
|
|
|
48
56
|
exporter.export
|
|
49
57
|
end
|
|
50
58
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
primary_config = Expressir::SchemaManifest.from_file(primary_path)
|
|
54
|
-
return primary_config unless additional_paths && !additional_paths.empty?
|
|
59
|
+
def load_schemas_from_files(files)
|
|
60
|
+
all_schemas = []
|
|
55
61
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
unless File.exist?(path)
|
|
59
|
-
raise Errno::ENOENT, "Specified additional manifest file " \
|
|
60
|
-
"`#{path}` not found."
|
|
61
|
-
end
|
|
62
|
-
Expressir::SchemaManifest.from_file(path)
|
|
62
|
+
files.each do |file|
|
|
63
|
+
all_schemas += process_file(file)
|
|
63
64
|
end
|
|
64
65
|
|
|
65
|
-
|
|
66
|
-
merge_all_configs(primary_config, additional_configs)
|
|
66
|
+
all_schemas
|
|
67
67
|
end
|
|
68
|
-
# rubocop:enable Metrics/MethodLength
|
|
69
|
-
|
|
70
|
-
def merge_all_configs(primary, additional_configs)
|
|
71
|
-
# Collect all schemas from primary and all additional manifests
|
|
72
|
-
all_schemas = primary.schemas.dup
|
|
73
68
|
|
|
74
|
-
|
|
75
|
-
|
|
69
|
+
def process_file(file)
|
|
70
|
+
case File.extname(file).downcase
|
|
71
|
+
when ".yml", ".yaml"
|
|
72
|
+
load_manifest_schemas(file)
|
|
73
|
+
when ".exp"
|
|
74
|
+
[create_schema_from_exp_file(file)]
|
|
75
|
+
else
|
|
76
|
+
raise ArgumentError, "Unsupported file type: #{file}. " \
|
|
77
|
+
"Only .yml, .yaml, and .exp files are " \
|
|
78
|
+
"supported."
|
|
76
79
|
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def load_manifest_schemas(file)
|
|
83
|
+
manifest = Expressir::SchemaManifest.from_file(file)
|
|
84
|
+
manifest.schemas
|
|
85
|
+
end
|
|
77
86
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
87
|
+
def create_schema_from_exp_file(exp_file)
|
|
88
|
+
# Create a schema object from a standalone EXPRESS file
|
|
89
|
+
# The id will be determined during parsing
|
|
90
|
+
ExportStandaloneSchema.new(
|
|
91
|
+
id: nil,
|
|
92
|
+
path: File.expand_path(exp_file),
|
|
81
93
|
)
|
|
82
94
|
end
|
|
83
95
|
|
data/lib/suma/cli.rb
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require "thor"
|
|
4
4
|
require_relative "thor_ext"
|
|
5
5
|
require_relative "cli/validate"
|
|
6
|
+
require "expressir/cli"
|
|
6
7
|
|
|
7
8
|
module Suma
|
|
8
9
|
module Cli
|
|
@@ -15,10 +16,9 @@ module Suma
|
|
|
15
16
|
option :compile, type: :boolean, default: true,
|
|
16
17
|
desc: "Compile or skip compile of collection"
|
|
17
18
|
option :schemas_all_path, type: :string, aliases: "-s",
|
|
18
|
-
desc: "Generate file that contains all
|
|
19
|
+
desc: "Generate file that contains all " \
|
|
20
|
+
"schemas in the collection."
|
|
19
21
|
def build(_site_manifest)
|
|
20
|
-
# # If no arguments, add an empty array to ensure the default command is triggered
|
|
21
|
-
# args = [] if args.empty?
|
|
22
22
|
require_relative "cli/build"
|
|
23
23
|
Cli::Build.start
|
|
24
24
|
end
|
|
@@ -60,24 +60,45 @@ module Suma
|
|
|
60
60
|
Cli::ConvertJsdai.start
|
|
61
61
|
end
|
|
62
62
|
|
|
63
|
-
desc "export
|
|
64
|
-
"Export EXPRESS schemas from manifest"
|
|
63
|
+
desc "export *FILES",
|
|
64
|
+
"Export EXPRESS schemas from manifest files or " \
|
|
65
|
+
"standalone EXPRESS files"
|
|
65
66
|
option :output, type: :string, aliases: "-o", required: true,
|
|
66
67
|
desc: "Output directory path"
|
|
67
|
-
option :additional, type: :array, aliases: "-a",
|
|
68
|
-
desc: "Additional schemas manifest files to merge (can be specified multiple times)"
|
|
69
68
|
option :annotations, type: :boolean, default: false,
|
|
70
69
|
desc: "Include annotations (remarks/comments)"
|
|
71
70
|
option :zip, type: :boolean, default: false,
|
|
72
71
|
desc: "Create ZIP archive of exported schemas"
|
|
73
|
-
def export(
|
|
72
|
+
def export(*_files)
|
|
74
73
|
require_relative "cli/export"
|
|
75
74
|
Cli::Export.start
|
|
76
75
|
end
|
|
77
76
|
|
|
77
|
+
desc "compare TRIAL_SCHEMA REFERENCE_SCHEMA",
|
|
78
|
+
"Compare EXPRESS schemas using eengine and generate Change YAML"
|
|
79
|
+
option :output, type: :string, aliases: "-o",
|
|
80
|
+
desc: "Output Change YAML file path"
|
|
81
|
+
option :version, type: :string, aliases: "-v", required: true,
|
|
82
|
+
desc: "Version number for this change edition"
|
|
83
|
+
option :mode, type: :string, default: "resource",
|
|
84
|
+
desc: "Schema comparison mode (resource/module)"
|
|
85
|
+
option :trial_stepmod, type: :string,
|
|
86
|
+
desc: "Override auto-detected trial repo root"
|
|
87
|
+
option :reference_stepmod, type: :string,
|
|
88
|
+
desc: "Override auto-detected reference repo root"
|
|
89
|
+
option :verbose, type: :boolean, default: false,
|
|
90
|
+
desc: "Enable verbose output"
|
|
91
|
+
def compare(_trial_schema, _reference_schema)
|
|
92
|
+
require_relative "cli/compare"
|
|
93
|
+
Cli::Compare.start
|
|
94
|
+
end
|
|
95
|
+
|
|
78
96
|
desc "validate SUBCOMMAND ...ARGS", "Validate express documents"
|
|
79
97
|
subcommand "validate", Cli::Validate
|
|
80
98
|
|
|
99
|
+
desc "expressir SUBCOMMAND ...ARGS", "Expressir commands"
|
|
100
|
+
subcommand "expressir", Expressir::Cli
|
|
101
|
+
|
|
81
102
|
def self.exit_on_failure?
|
|
82
103
|
true
|
|
83
104
|
end
|
|
@@ -36,12 +36,25 @@ module Suma
|
|
|
36
36
|
model.entry = CollectionManifest.from_yaml(value.to_yaml)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Recursively exports schema configuration by traversing collection manifests.
|
|
40
|
+
#
|
|
41
|
+
# This method builds an EXPRESS Schema Manifest (Expressir::SchemaManifest) by:
|
|
42
|
+
# 1. Starting with an empty or existing Expressir::SchemaManifest
|
|
43
|
+
# 2. Recursively traversing child entries to collect schemas
|
|
44
|
+
# 3. Using Expressir::SchemaManifest#concat to combine manifests
|
|
45
|
+
#
|
|
46
|
+
# The actual schema manifest operations (creation, concatenation, serialization)
|
|
47
|
+
# are handled by Expressir's SchemaManifest class, keeping the logic DRY.
|
|
48
|
+
#
|
|
49
|
+
# @param path [String] Base path for resolving relative schema paths
|
|
50
|
+
# @return [Expressir::SchemaManifest] Combined schema manifest
|
|
39
51
|
def export_schema_config(path)
|
|
40
52
|
export_config = @schema_config || Expressir::SchemaManifest.new
|
|
41
53
|
return export_config unless entry
|
|
42
54
|
|
|
43
55
|
entry.each do |x|
|
|
44
56
|
child_config = x.export_schema_config(path)
|
|
57
|
+
# Use Expressir's concat method to combine schema manifests
|
|
45
58
|
export_config.concat(child_config) if child_config
|
|
46
59
|
end
|
|
47
60
|
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Suma
|
|
4
|
+
module Eengine
|
|
5
|
+
# Base error class for eengine-related errors
|
|
6
|
+
class EengineError < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Raised when eengine binary is not found in PATH
|
|
9
|
+
class EengineNotFoundError < EengineError
|
|
10
|
+
def initialize
|
|
11
|
+
super("eengine not found in PATH. Install eengine:\n " \
|
|
12
|
+
"macOS: https://github.com/expresslang/homebrew-eengine\n " \
|
|
13
|
+
"Linux: https://github.com/expresslang/eengine-releases")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Raised when eengine comparison fails
|
|
18
|
+
class ComparisonError < EengineError
|
|
19
|
+
attr_reader :stderr
|
|
20
|
+
|
|
21
|
+
def initialize(message, stderr = nil)
|
|
22
|
+
super(message)
|
|
23
|
+
@stderr = stderr
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require_relative "errors"
|
|
5
|
+
|
|
6
|
+
module Suma
|
|
7
|
+
module Eengine
|
|
8
|
+
# Wrapper for eengine binary to compare EXPRESS schemas
|
|
9
|
+
class Wrapper
|
|
10
|
+
class << self
|
|
11
|
+
# Compare two EXPRESS schemas using eengine
|
|
12
|
+
#
|
|
13
|
+
# @param trial_schema [String] Path to the new/trial schema
|
|
14
|
+
# @param reference_schema [String] Path to the old/reference schema
|
|
15
|
+
# @param options [Hash] Comparison options
|
|
16
|
+
# @option options [String] :mode Comparison mode (resource/module)
|
|
17
|
+
# @option options [String] :trial_stepmod Path to trial repo root
|
|
18
|
+
# @option options [String] :reference_stepmod Path to reference repo root
|
|
19
|
+
# @return [Hash] Result with :success, :xml_path, :has_changes, :output
|
|
20
|
+
def compare(trial_schema, reference_schema, options = {})
|
|
21
|
+
ensure_eengine_available!
|
|
22
|
+
|
|
23
|
+
cmd = build_command(trial_schema, reference_schema, options)
|
|
24
|
+
output, error, status = Open3.capture3(*cmd)
|
|
25
|
+
|
|
26
|
+
unless status.success?
|
|
27
|
+
error_message = error.empty? ? "Unknown eengine error" : error.strip
|
|
28
|
+
raise ComparisonError.new(error_message, error)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
parse_output(output, options)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check if eengine is available on the system
|
|
35
|
+
#
|
|
36
|
+
# @return [Boolean] true if eengine binary is found
|
|
37
|
+
def available?
|
|
38
|
+
return false if ENV["EENGINE_DISABLED"] == "true"
|
|
39
|
+
|
|
40
|
+
eengine_path && eengine_executable?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Get the eengine version
|
|
44
|
+
#
|
|
45
|
+
# @return [String, nil] Version string or nil if unavailable
|
|
46
|
+
def version
|
|
47
|
+
return nil unless available?
|
|
48
|
+
|
|
49
|
+
cmd = [eengine_path, "--version"]
|
|
50
|
+
output, _, status = Open3.capture3(*cmd)
|
|
51
|
+
status.success? ? parse_version(output) : nil
|
|
52
|
+
rescue StandardError
|
|
53
|
+
nil
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def eengine_path
|
|
59
|
+
@eengine_path ||= find_eengine_binary
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def find_eengine_binary
|
|
63
|
+
# Search for eengine or eengine-* in PATH
|
|
64
|
+
ENV["PATH"].split(File::PATH_SEPARATOR).each do |dir|
|
|
65
|
+
# First try plain eengine
|
|
66
|
+
plain_path = File.join(dir, "eengine")
|
|
67
|
+
return plain_path if File.exist?(plain_path) && File.executable?(plain_path)
|
|
68
|
+
|
|
69
|
+
# Then try eengine-* pattern
|
|
70
|
+
Dir.glob(File.join(dir, "eengine-*")).each do |path|
|
|
71
|
+
return path if File.executable?(path)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def eengine_executable?
|
|
78
|
+
eengine_path && File.executable?(eengine_path)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def ensure_eengine_available!
|
|
82
|
+
raise EengineNotFoundError unless available?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def build_command(trial, reference, options)
|
|
86
|
+
cmd = [
|
|
87
|
+
eengine_path,
|
|
88
|
+
"--compare",
|
|
89
|
+
"-trial_schema", trial,
|
|
90
|
+
"-trial_stepmod", options[:trial_stepmod] || ".",
|
|
91
|
+
"-reference_schema", reference,
|
|
92
|
+
"-reference_stepmod", options[:reference_stepmod] || ".",
|
|
93
|
+
"-mode", options[:mode] || "resource",
|
|
94
|
+
"--xml-output"
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
# Add output directory if specified
|
|
98
|
+
if options[:out_dir]
|
|
99
|
+
cmd += ["-out-dir", options[:out_dir]]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
cmd
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parse_output(output, _options)
|
|
106
|
+
# Extract XML file path from output
|
|
107
|
+
# eengine prints: "Writing \"path/to/file.xml\""
|
|
108
|
+
xml_match = output.match(/Writing "(.+\.xml)"/)
|
|
109
|
+
xml_path = xml_match ? xml_match[1] : nil
|
|
110
|
+
|
|
111
|
+
# Expand to absolute path if found
|
|
112
|
+
xml_path = File.expand_path(xml_path) if xml_path
|
|
113
|
+
|
|
114
|
+
# Determine if changes were detected
|
|
115
|
+
has_changes = detect_changes(output)
|
|
116
|
+
|
|
117
|
+
{
|
|
118
|
+
success: true,
|
|
119
|
+
xml_path: xml_path,
|
|
120
|
+
has_changes: has_changes,
|
|
121
|
+
output: output,
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def detect_changes(output)
|
|
126
|
+
# Check for various indicators of changes in the output
|
|
127
|
+
return true if output.include?("Comparing TYPE")
|
|
128
|
+
return true if output.include?("Comparing ENTITY")
|
|
129
|
+
return true if output.include?("Comparing FUNCTION")
|
|
130
|
+
return true if output.include?("Comparing RULE")
|
|
131
|
+
return true if output.include?("Comparing PROCEDURE")
|
|
132
|
+
|
|
133
|
+
# Check for modification indicators
|
|
134
|
+
return true if output.include?("changed")
|
|
135
|
+
return true if output.include?("modified")
|
|
136
|
+
return true if output.include?("added")
|
|
137
|
+
return true if output.include?("removed")
|
|
138
|
+
|
|
139
|
+
false
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def parse_version(output)
|
|
143
|
+
# Extract version from output like "Express Engine 5.2.7"
|
|
144
|
+
version_match = output.match(/Express Engine ([\d.]+)/)
|
|
145
|
+
version_match ? version_match[1] : nil
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "expressir/commands/changes_import_eengine"
|
|
4
|
+
|
|
5
|
+
module Suma
|
|
6
|
+
# Converts eengine comparison XML to Expressir::Changes::SchemaChange
|
|
7
|
+
# This is a thin wrapper around Expressir's ChangesImportEengine command
|
|
8
|
+
class EengineConverter
|
|
9
|
+
def initialize(xml_path, schema_name)
|
|
10
|
+
@xml_path = xml_path
|
|
11
|
+
@schema_name = schema_name
|
|
12
|
+
@xml_content = File.read(xml_path)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Convert the eengine XML to a ChangeSchema
|
|
16
|
+
#
|
|
17
|
+
# @param version [String] Version number for this change edition
|
|
18
|
+
# @param existing_change_schema [Expressir::Changes::SchemaChange, nil]
|
|
19
|
+
# Existing schema to append to, or nil to create new
|
|
20
|
+
# @return [Expressir::Changes::SchemaChange] The updated change schema
|
|
21
|
+
def convert(version:, existing_change_schema: nil)
|
|
22
|
+
# Use Expressir's built-in conversion which properly handles
|
|
23
|
+
# HTML elements in descriptions
|
|
24
|
+
Expressir::Commands::ChangesImportEengine.from_xml(
|
|
25
|
+
@xml_content,
|
|
26
|
+
@schema_name,
|
|
27
|
+
version,
|
|
28
|
+
existing_schema: existing_change_schema,
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Suma
|
|
4
|
+
# Simple schema class for standalone EXPRESS files
|
|
5
|
+
# Used when exporting individual .exp files that are not part of a manifest
|
|
6
|
+
class ExportStandaloneSchema
|
|
7
|
+
attr_accessor :id, :path
|
|
8
|
+
|
|
9
|
+
def initialize(id:, path:)
|
|
10
|
+
@id = id
|
|
11
|
+
@path = path
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|