expressir 2.1.18 → 2.1.20
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_todo.yml +30 -9
- data/README.adoc +354 -3
- data/expressir.gemspec +2 -1
- data/lib/expressir/cli.rb +29 -193
- data/lib/expressir/commands/base.rb +32 -0
- data/lib/expressir/commands/benchmark.rb +68 -0
- data/lib/expressir/commands/benchmark_cache.rb +88 -0
- data/lib/expressir/commands/clean.rb +20 -0
- data/lib/expressir/commands/coverage.rb +342 -0
- data/lib/expressir/commands/format.rb +13 -0
- data/lib/expressir/commands/validate.rb +53 -0
- data/lib/expressir/commands/version.rb +9 -0
- data/lib/expressir/coverage.rb +474 -0
- data/lib/expressir/express/formatter.rb +4 -2
- data/lib/expressir/express/visitor.rb +10 -6
- data/lib/expressir/model/declarations/derived_attribute.rb +25 -0
- data/lib/expressir/model/declarations/inverse_attribute.rb +25 -0
- data/lib/expressir/model/model_element.rb +2 -0
- data/lib/expressir/model.rb +94 -71
- data/lib/expressir/version.rb +1 -1
- data/lib/expressir.rb +122 -6
- metadata +33 -8
@@ -0,0 +1,342 @@
|
|
1
|
+
require "terminal-table"
|
2
|
+
require "json"
|
3
|
+
require "yaml"
|
4
|
+
require "ruby-progressbar"
|
5
|
+
|
6
|
+
module Expressir
|
7
|
+
module Commands
|
8
|
+
class Coverage < Base
|
9
|
+
def run(paths)
|
10
|
+
if paths.empty?
|
11
|
+
exit_with_error "No paths specified. Please provide paths to EXPRESS files or directories."
|
12
|
+
end
|
13
|
+
|
14
|
+
reports = collect_reports(paths)
|
15
|
+
|
16
|
+
if reports.empty?
|
17
|
+
exit_with_error "No valid EXPRESS files were processed. Nothing to report."
|
18
|
+
end
|
19
|
+
|
20
|
+
# Generate output based on format
|
21
|
+
case options[:format].downcase
|
22
|
+
when "json"
|
23
|
+
display_json_output(reports)
|
24
|
+
when "yaml"
|
25
|
+
display_yaml_output(reports)
|
26
|
+
else # Default to text
|
27
|
+
display_text_output(reports)
|
28
|
+
end
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def collect_reports(paths)
|
34
|
+
reports = []
|
35
|
+
|
36
|
+
paths.each do |path|
|
37
|
+
handle_path(path, reports)
|
38
|
+
end
|
39
|
+
|
40
|
+
reports
|
41
|
+
end
|
42
|
+
|
43
|
+
def handle_path(path, reports)
|
44
|
+
if File.directory?(path)
|
45
|
+
handle_directory(path, reports)
|
46
|
+
elsif File.extname(path).downcase == ".exp"
|
47
|
+
handle_express_file(path, reports)
|
48
|
+
elsif [".yml", ".yaml"].include?(File.extname(path).downcase)
|
49
|
+
handle_yaml_manifest(path, reports)
|
50
|
+
else
|
51
|
+
say "Unsupported file type: #{path}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
def handle_directory(path, reports)
|
56
|
+
say "Processing directory: #{path}"
|
57
|
+
exp_files = Dir.glob(File.join(path, "**", "*.exp"))
|
58
|
+
if exp_files.empty?
|
59
|
+
say "No EXPRESS files found in directory: #{path}"
|
60
|
+
return
|
61
|
+
end
|
62
|
+
|
63
|
+
say "Found #{exp_files.size} EXPRESS files to process"
|
64
|
+
|
65
|
+
# Initialize progress bar for directory files
|
66
|
+
progress = ProgressBar.create(
|
67
|
+
title: "Processing files",
|
68
|
+
total: exp_files.size,
|
69
|
+
format: "%t: [%B] %p%% %a [%c/%C] %e",
|
70
|
+
output: $stdout,
|
71
|
+
)
|
72
|
+
|
73
|
+
# Parse all files and create a repository with progress tracking
|
74
|
+
begin
|
75
|
+
repository = Expressir::Express::Parser.from_files(exp_files) do |filename, _schemas, error|
|
76
|
+
if error
|
77
|
+
say " Error processing #{File.basename(filename)}: #{error.message}"
|
78
|
+
end
|
79
|
+
progress.increment
|
80
|
+
end
|
81
|
+
skip_types = parse_skip_types
|
82
|
+
report = Expressir::Coverage::Report.from_repository(repository, skip_types)
|
83
|
+
reports << report
|
84
|
+
rescue StandardError => e
|
85
|
+
say "Error processing directory #{path}: #{e.message}"
|
86
|
+
end
|
87
|
+
end
|
88
|
+
|
89
|
+
def handle_express_file(path, reports)
|
90
|
+
say "Processing file: #{path}"
|
91
|
+
begin
|
92
|
+
# For a single file, we don't need a progress bar
|
93
|
+
skip_types = parse_skip_types
|
94
|
+
report = Expressir::Coverage::Report.from_file(path, skip_types)
|
95
|
+
reports << report
|
96
|
+
rescue StandardError => e
|
97
|
+
say "Error processing file #{path}: #{e.message}"
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
def handle_yaml_manifest(path, reports)
|
102
|
+
say "Processing YAML manifest: #{path}"
|
103
|
+
begin
|
104
|
+
schema_list = YAML.load_file(path)
|
105
|
+
manifest_dir = File.dirname(path)
|
106
|
+
|
107
|
+
if schema_list.is_a?(Hash) && schema_list["schemas"]
|
108
|
+
schemas_data = schema_list["schemas"]
|
109
|
+
|
110
|
+
# Handle the nested structure with schema name keys and path values
|
111
|
+
if schemas_data.is_a?(Hash)
|
112
|
+
schema_files = schemas_data.values.map do |schema_data|
|
113
|
+
if schema_data.is_a?(Hash) && schema_data["path"]
|
114
|
+
# Make path relative to the manifest location
|
115
|
+
File.expand_path(schema_data["path"], manifest_dir)
|
116
|
+
end
|
117
|
+
end.compact
|
118
|
+
|
119
|
+
say "Found #{schema_files.size} schema files to process"
|
120
|
+
else
|
121
|
+
# If it's a direct array of paths (old format)
|
122
|
+
schema_files = schemas_data
|
123
|
+
end
|
124
|
+
elsif schema_list.is_a?(Array)
|
125
|
+
schema_files = schema_list
|
126
|
+
else
|
127
|
+
say "Invalid YAML format. Expected an array of schema paths or a hash with a 'schemas' key."
|
128
|
+
return
|
129
|
+
end
|
130
|
+
|
131
|
+
# Initialize progress bar
|
132
|
+
if schema_files && !schema_files.empty?
|
133
|
+
say "Processing schemas from manifest file"
|
134
|
+
|
135
|
+
progress = ProgressBar.create(
|
136
|
+
title: "Processing schemas",
|
137
|
+
total: schema_files.size,
|
138
|
+
format: "%t: [%B] %p%% %a [%c/%C] %e",
|
139
|
+
output: $stdout,
|
140
|
+
)
|
141
|
+
|
142
|
+
# Process files with progress tracking
|
143
|
+
repository = Expressir::Express::Parser.from_files(schema_files) do |filename, _schemas, error|
|
144
|
+
if error
|
145
|
+
say " Error processing #{File.basename(filename)}: #{error.message}"
|
146
|
+
end
|
147
|
+
progress.increment
|
148
|
+
end
|
149
|
+
|
150
|
+
# Create and add the report
|
151
|
+
skip_types = parse_skip_types
|
152
|
+
report = Expressir::Coverage::Report.from_repository(repository, skip_types)
|
153
|
+
reports << report
|
154
|
+
end
|
155
|
+
rescue StandardError => e
|
156
|
+
say "Error processing YAML manifest #{path}: #{e.message}"
|
157
|
+
say "Debug: schema_list structure: #{schema_list.class}" if schema_list
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def display_text_output(reports)
|
162
|
+
say "\nEXPRESS Documentation Coverage"
|
163
|
+
say "=============================="
|
164
|
+
|
165
|
+
# If multiple reports, display directory coverage first
|
166
|
+
if reports.size > 1
|
167
|
+
display_directory_coverage(reports)
|
168
|
+
end
|
169
|
+
|
170
|
+
display_file_coverage(reports)
|
171
|
+
display_overall_stats(reports)
|
172
|
+
end
|
173
|
+
|
174
|
+
def display_directory_coverage(reports)
|
175
|
+
say "\nDirectory Coverage:"
|
176
|
+
|
177
|
+
# Collect directory data from all reports
|
178
|
+
dirs = {}
|
179
|
+
reports.each do |report|
|
180
|
+
report.directory_reports.each do |dir_report|
|
181
|
+
dir = dir_report["directory"]
|
182
|
+
dirs[dir] ||= { "total" => 0, "documented" => 0 }
|
183
|
+
dirs[dir]["total"] += dir_report["total"]
|
184
|
+
dirs[dir]["documented"] += dir_report["documented"]
|
185
|
+
end
|
186
|
+
end
|
187
|
+
|
188
|
+
# Create table
|
189
|
+
table = Terminal::Table.new(
|
190
|
+
title: "Directory Coverage",
|
191
|
+
headings: ["Directory", "Total", "Documented", "Coverage %"],
|
192
|
+
style: {
|
193
|
+
border_x: "-",
|
194
|
+
border_y: "|",
|
195
|
+
border_i: "+",
|
196
|
+
},
|
197
|
+
)
|
198
|
+
|
199
|
+
# Add rows
|
200
|
+
dirs.each do |dir, stats|
|
201
|
+
coverage = stats["total"].positive? ? (stats["documented"].to_f / stats["total"] * 100).round(2) : 100.0
|
202
|
+
table.add_row [dir, stats["total"], stats["documented"], "#{coverage}%"]
|
203
|
+
end
|
204
|
+
|
205
|
+
say table
|
206
|
+
end
|
207
|
+
|
208
|
+
def display_file_coverage(reports)
|
209
|
+
say "\nFile Coverage:"
|
210
|
+
|
211
|
+
# Create table
|
212
|
+
table = Terminal::Table.new(
|
213
|
+
title: "File Coverage",
|
214
|
+
headings: ["File", "Undocumented Entities", "Coverage %"],
|
215
|
+
style: {
|
216
|
+
border_x: "-",
|
217
|
+
border_y: "|",
|
218
|
+
border_i: "+",
|
219
|
+
},
|
220
|
+
)
|
221
|
+
|
222
|
+
reports.each do |report|
|
223
|
+
report.file_reports.each do |file_report|
|
224
|
+
file_path = file_report["file"]
|
225
|
+
|
226
|
+
# Format undocumented entities as "TYPE name, TYPE name, ..."
|
227
|
+
undocumented_formatted = file_report["undocumented"].map do |entity_info|
|
228
|
+
"#{entity_info['type']} #{entity_info['name']}"
|
229
|
+
end.join(", ")
|
230
|
+
|
231
|
+
coverage = file_report["coverage"].round(2)
|
232
|
+
table.add_row [file_path, undocumented_formatted, "#{coverage}%"]
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
236
|
+
say table
|
237
|
+
end
|
238
|
+
|
239
|
+
def display_overall_stats(reports)
|
240
|
+
# Get structured report for overall statistics
|
241
|
+
overall = build_structured_report(reports)["overall"]
|
242
|
+
|
243
|
+
table = Terminal::Table.new(
|
244
|
+
title: "Overall Documentation Coverage",
|
245
|
+
style: {
|
246
|
+
border_x: "-",
|
247
|
+
border_y: "|",
|
248
|
+
border_i: "+",
|
249
|
+
},
|
250
|
+
)
|
251
|
+
|
252
|
+
table.add_row ["Coverage Percentage", "#{overall['coverage_percentage']}%"]
|
253
|
+
table.add_row ["Total Entities", overall["total_entities"]]
|
254
|
+
table.add_row ["Documented Entities", overall["documented_entities"]]
|
255
|
+
table.add_row ["Undocumented Entities", overall["undocumented_entities"]]
|
256
|
+
|
257
|
+
say table
|
258
|
+
end
|
259
|
+
|
260
|
+
def build_structured_report(reports)
|
261
|
+
{
|
262
|
+
"overall" => {
|
263
|
+
"total_entities" => reports.sum { |r| r.total_entities.size },
|
264
|
+
"documented_entities" => reports.sum { |r| r.documented_entities.size },
|
265
|
+
"undocumented_entities" => reports.sum { |r| r.undocumented_entities.size },
|
266
|
+
"coverage_percentage" => if reports.sum { |r| r.total_entities.size }.positive?
|
267
|
+
(reports.sum { |r| r.documented_entities.size }.to_f / reports.sum { |r| r.total_entities.size } * 100).round(2)
|
268
|
+
else
|
269
|
+
100.0
|
270
|
+
end,
|
271
|
+
},
|
272
|
+
"files" => reports.flat_map(&:file_reports),
|
273
|
+
"directories" => reports.flat_map(&:directory_reports),
|
274
|
+
}
|
275
|
+
end
|
276
|
+
|
277
|
+
def display_json_output(reports)
|
278
|
+
output_file = options[:output] || "coverage_report.json"
|
279
|
+
File.write(output_file, JSON.pretty_generate(build_structured_report(reports)))
|
280
|
+
say "JSON coverage report written to: #{output_file}"
|
281
|
+
end
|
282
|
+
|
283
|
+
def display_yaml_output(reports)
|
284
|
+
output_file = options[:output] || "coverage_report.yaml"
|
285
|
+
File.write(output_file, build_structured_report(reports).to_yaml)
|
286
|
+
say "YAML coverage report written to: #{output_file}"
|
287
|
+
end
|
288
|
+
|
289
|
+
# Parse and validate the skip_types option
|
290
|
+
# @return [Array<String>] Array of validated entity type names
|
291
|
+
def parse_skip_types
|
292
|
+
skip_types_option = options["exclude"] || options[:exclude]
|
293
|
+
return [] unless skip_types_option
|
294
|
+
|
295
|
+
# Handle both string (comma-separated) and array inputs
|
296
|
+
requested_types = if skip_types_option.is_a?(Array)
|
297
|
+
skip_types_option.map(&:to_s).map(&:strip).map(&:upcase)
|
298
|
+
else
|
299
|
+
skip_types_option.split(",").map(&:strip).map(&:upcase)
|
300
|
+
end
|
301
|
+
|
302
|
+
# Validate each type (supports both TYPE and TYPE:SUBTYPE formats)
|
303
|
+
requested_types.each do |type|
|
304
|
+
validate_skip_type(type)
|
305
|
+
end
|
306
|
+
|
307
|
+
requested_types
|
308
|
+
end
|
309
|
+
|
310
|
+
# Validate a single skip type (supports TYPE:SUBTYPE syntax)
|
311
|
+
# @param type [String] The type to validate
|
312
|
+
def validate_skip_type(type)
|
313
|
+
if type.include?(":")
|
314
|
+
# Handle TYPE:SUBTYPE format
|
315
|
+
main_type, subtype = type.split(":", 2)
|
316
|
+
|
317
|
+
# Validate main type
|
318
|
+
unless Expressir::Coverage::ENTITY_TYPE_MAP.key?(main_type)
|
319
|
+
exit_with_error "Invalid entity type: #{main_type}. " \
|
320
|
+
"Valid types are: #{Expressir::Coverage::ENTITY_TYPE_MAP.keys.join(', ')}"
|
321
|
+
end
|
322
|
+
|
323
|
+
# For TYPE, validate subtype
|
324
|
+
if main_type == "TYPE"
|
325
|
+
unless Expressir::Coverage::TYPE_SUBTYPES.include?(subtype)
|
326
|
+
exit_with_error "Invalid TYPE subtype: #{subtype}. " \
|
327
|
+
"Valid TYPE subtypes are: #{Expressir::Coverage::TYPE_SUBTYPES.join(', ')}"
|
328
|
+
end
|
329
|
+
else
|
330
|
+
exit_with_error "Subtype syntax (#{type}) is only supported for TYPE entities"
|
331
|
+
end
|
332
|
+
else
|
333
|
+
# Handle simple type format
|
334
|
+
unless Expressir::Coverage::ENTITY_TYPE_MAP.key?(type)
|
335
|
+
exit_with_error "Invalid entity type: #{type}. " \
|
336
|
+
"Valid types are: #{Expressir::Coverage::ENTITY_TYPE_MAP.keys.join(', ')}"
|
337
|
+
end
|
338
|
+
end
|
339
|
+
end
|
340
|
+
end
|
341
|
+
end
|
342
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module Expressir
|
2
|
+
module Commands
|
3
|
+
class Format < Base
|
4
|
+
def run(path)
|
5
|
+
repository = Expressir::Express::Parser.from_file(path)
|
6
|
+
repository.schemas.each do |schema|
|
7
|
+
say "\n(* Expressir formatted schema: #{schema.id} *)\n"
|
8
|
+
say schema.to_s(no_remarks: true)
|
9
|
+
end
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,53 @@
|
|
1
|
+
module Expressir
|
2
|
+
module Commands
|
3
|
+
class Validate < Base
|
4
|
+
def run(paths)
|
5
|
+
no_version = []
|
6
|
+
no_valid = []
|
7
|
+
|
8
|
+
paths.each do |path|
|
9
|
+
x = Pathname.new(path).realpath.relative_path_from(Dir.pwd)
|
10
|
+
say "Validating #{x}"
|
11
|
+
ret = validate_schema(path)
|
12
|
+
|
13
|
+
if ret.nil?
|
14
|
+
no_valid << "Failed to parse: #{x}"
|
15
|
+
next
|
16
|
+
end
|
17
|
+
|
18
|
+
ret.each do |schema_id|
|
19
|
+
no_version << "Missing version string: schema `#{schema_id}` | #{x}"
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
print_validation_errors(:failed_to_parse, no_valid)
|
24
|
+
print_validation_errors(:missing_version_string, no_version)
|
25
|
+
|
26
|
+
exit 1 unless [no_valid, no_version].all?(&:empty?)
|
27
|
+
|
28
|
+
say "Validation passed for all EXPRESS schemas."
|
29
|
+
end
|
30
|
+
|
31
|
+
private
|
32
|
+
|
33
|
+
def validate_schema(path)
|
34
|
+
repository = Expressir::Express::Parser.from_file(path)
|
35
|
+
repository.schemas.inject([]) do |acc, schema|
|
36
|
+
acc << schema.id unless schema.version&.value
|
37
|
+
acc
|
38
|
+
end
|
39
|
+
rescue StandardError
|
40
|
+
nil
|
41
|
+
end
|
42
|
+
|
43
|
+
def print_validation_errors(type, array)
|
44
|
+
return if array.empty?
|
45
|
+
|
46
|
+
say "#{'*' * 20} RESULTS: #{type.to_s.upcase.tr('_', ' ')} #{'*' * 20}"
|
47
|
+
array.each do |msg|
|
48
|
+
say msg
|
49
|
+
end
|
50
|
+
end
|
51
|
+
end
|
52
|
+
end
|
53
|
+
end
|