suma 0.2.5 → 0.2.6
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/.github/workflows/rake.yml +3 -0
- data/.github/workflows/release.yml +5 -1
- data/.rubocop_todo.yml +78 -26
- data/CLAUDE.md +76 -0
- data/Gemfile +3 -1
- data/README.adoc +131 -0
- data/lib/suma/cli/build.rb +2 -3
- data/lib/suma/cli/check_svg_quality.rb +178 -0
- data/lib/suma/cli/compare.rb +7 -158
- data/lib/suma/cli/export.rb +1 -7
- data/lib/suma/cli/extract_terms.rb +7 -648
- data/lib/suma/cli/generate_schemas.rb +9 -123
- data/lib/suma/cli/validate_links.rb +15 -290
- data/lib/suma/cli.rb +39 -0
- data/lib/suma/collection_manifest.rb +3 -4
- data/lib/suma/express_schema.rb +43 -30
- data/lib/suma/jsdai/figure_xml.rb +12 -9
- data/lib/suma/jsdai.rb +0 -6
- data/lib/suma/link_validator.rb +203 -0
- data/lib/suma/processor.rb +75 -101
- data/lib/suma/schema_attachment.rb +2 -29
- data/lib/suma/schema_collection.rb +1 -32
- data/lib/suma/schema_comparer.rb +116 -0
- data/lib/suma/schema_document.rb +0 -14
- data/lib/suma/schema_exporter.rb +16 -28
- data/lib/suma/schema_index.rb +53 -0
- data/lib/suma/schema_manifest_generator.rb +105 -0
- data/lib/suma/svg_quality/batch_report.rb +80 -0
- data/lib/suma/svg_quality/formatters/json_formatter.rb +30 -0
- data/lib/suma/svg_quality/formatters/terminal_formatter.rb +168 -0
- data/lib/suma/svg_quality/formatters/yaml_formatter.rb +32 -0
- data/lib/suma/svg_quality/report.rb +52 -0
- data/lib/suma/svg_quality.rb +28 -0
- data/lib/suma/term_extractor.rb +393 -0
- data/lib/suma/utils.rb +10 -2
- data/lib/suma/version.rb +1 -1
- data/lib/suma.rb +3 -2
- data/suma.gemspec +3 -2
- metadata +33 -7
- data/lib/suma/export_standalone_schema.rb +0 -14
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
require_relative "eengine/wrapper"
|
|
6
|
+
require_relative "eengine_converter"
|
|
7
|
+
|
|
8
|
+
module Suma
|
|
9
|
+
class SchemaComparer
|
|
10
|
+
attr_reader :trial_schema, :reference_schema, :options
|
|
11
|
+
|
|
12
|
+
def initialize(trial_schema, reference_schema, options = {})
|
|
13
|
+
@trial_schema = trial_schema
|
|
14
|
+
@reference_schema = reference_schema
|
|
15
|
+
@options = options
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def compare
|
|
19
|
+
validate_inputs
|
|
20
|
+
|
|
21
|
+
trial_stepmod = options[:trial_stepmod] || detect_repo_root(trial_schema)
|
|
22
|
+
reference_stepmod = options[:reference_stepmod] || detect_repo_root(reference_schema)
|
|
23
|
+
|
|
24
|
+
out_dir = Dir.mktmpdir("eengine-compare-")
|
|
25
|
+
|
|
26
|
+
result = Eengine::Wrapper.compare(
|
|
27
|
+
trial_schema,
|
|
28
|
+
reference_schema,
|
|
29
|
+
mode: options[:mode] || "resource",
|
|
30
|
+
trial_stepmod: trial_stepmod,
|
|
31
|
+
reference_stepmod: reference_stepmod,
|
|
32
|
+
out_dir: out_dir,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
unless result[:has_changes]
|
|
36
|
+
FileUtils.rm_rf(out_dir) if File.directory?(out_dir)
|
|
37
|
+
return nil
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
raise Suma::CompilationError, "XML output not found" unless result[:xml_path]
|
|
41
|
+
|
|
42
|
+
convert_to_change_yaml(result[:xml_path], out_dir)
|
|
43
|
+
ensure
|
|
44
|
+
FileUtils.rm_rf(out_dir) if out_dir && File.directory?(out_dir)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def validate_inputs
|
|
50
|
+
unless File.exist?(trial_schema)
|
|
51
|
+
raise Suma::SchemaNotFoundError,
|
|
52
|
+
"Trial schema not found: #{trial_schema}"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
unless File.exist?(reference_schema)
|
|
56
|
+
raise Suma::SchemaNotFoundError,
|
|
57
|
+
"Reference schema not found: #{reference_schema}"
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
unless Eengine::Wrapper.available?
|
|
61
|
+
raise Suma::EengineNotAvailableError,
|
|
62
|
+
"eengine not found in PATH. Install from:\n " \
|
|
63
|
+
"macOS: https://github.com/expresslang/homebrew-eengine\n " \
|
|
64
|
+
"Linux: https://github.com/expresslang/eengine-releases"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def detect_repo_root(schema_path)
|
|
69
|
+
current = File.expand_path(File.dirname(schema_path))
|
|
70
|
+
|
|
71
|
+
loop do
|
|
72
|
+
return current if File.directory?(File.join(current, ".git"))
|
|
73
|
+
|
|
74
|
+
parent = File.dirname(current)
|
|
75
|
+
break if parent == current
|
|
76
|
+
|
|
77
|
+
current = parent
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
File.dirname(schema_path)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def convert_to_change_yaml(xml_path, _out_dir)
|
|
84
|
+
schema_name = extract_schema_name(trial_schema)
|
|
85
|
+
output_path = determine_output_path
|
|
86
|
+
|
|
87
|
+
existing_schema = nil
|
|
88
|
+
if File.exist?(output_path)
|
|
89
|
+
require "expressir/changes"
|
|
90
|
+
existing_schema = Expressir::Changes::SchemaChange.from_file(output_path)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
converter = EengineConverter.new(xml_path, schema_name)
|
|
94
|
+
change_schema = converter.convert(
|
|
95
|
+
version: options[:version],
|
|
96
|
+
existing_change_schema: existing_schema,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
change_schema.to_file(output_path)
|
|
100
|
+
output_path
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def extract_schema_name(path)
|
|
104
|
+
basename = File.basename(path, ".exp")
|
|
105
|
+
basename.sub(/_\d+$/, "")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def determine_output_path
|
|
109
|
+
options[:output] || begin
|
|
110
|
+
base = extract_schema_name(trial_schema)
|
|
111
|
+
dir = File.dirname(trial_schema)
|
|
112
|
+
File.join(dir, "#{base}.changes.yaml")
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
data/lib/suma/schema_document.rb
CHANGED
|
@@ -85,24 +85,10 @@ module Suma
|
|
|
85
85
|
HEREDOC
|
|
86
86
|
end
|
|
87
87
|
|
|
88
|
-
# ////
|
|
89
|
-
# TODO:
|
|
90
|
-
# % render "templates/entities", schema: schema, schema_id: schema.id, things: schema.entities, thing_prefix: root_thing_prefix, depth: 2 %
|
|
91
|
-
#
|
|
92
|
-
# % render "templates/subtype_constraints", schema_id: schema.id, things: schema.subtype_constraints, thing_prefix: root_thing_prefix, depth: 2 %
|
|
93
|
-
#
|
|
94
|
-
# % render "templates/functions", schema_id: schema.id, things: schema.functions, thing_prefix: root_thing_prefix, depth: 2 %
|
|
95
|
-
#
|
|
96
|
-
# % render "templates/procedures", schema_id: schema.id, things: schema.procedures, thing_prefix: root_thing_prefix, depth: 2 %
|
|
97
|
-
#
|
|
98
|
-
# % render "templates/rules", schema_id: schema.id, things: schema.rules, thing_prefix: root_thing_prefix, depth: 2 %
|
|
99
|
-
# ////
|
|
100
|
-
|
|
101
88
|
def output_extensions
|
|
102
89
|
"xml"
|
|
103
90
|
end
|
|
104
91
|
|
|
105
|
-
# #.gsub(/[\n\r]{2,}/, '')
|
|
106
92
|
def to_adoc(path_to_schema_yaml)
|
|
107
93
|
<<~HEREDOC
|
|
108
94
|
= #{@schema.id}
|
data/lib/suma/schema_exporter.rb
CHANGED
|
@@ -2,13 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "express_schema"
|
|
4
4
|
require_relative "utils"
|
|
5
|
-
require_relative "export_standalone_schema"
|
|
6
5
|
require "fileutils"
|
|
7
6
|
|
|
8
7
|
module Suma
|
|
9
8
|
# SchemaExporter exports EXPRESS schemas from a manifest
|
|
10
9
|
# with configurable options for annotations and ZIP packaging
|
|
11
10
|
class SchemaExporter
|
|
11
|
+
CATEGORY_MAP = {
|
|
12
|
+
ExpressSchema::Type::RESOURCE => "resources",
|
|
13
|
+
ExpressSchema::Type::MODULE_ARM => "modules",
|
|
14
|
+
ExpressSchema::Type::MODULE_MIM => "modules",
|
|
15
|
+
ExpressSchema::Type::BUSINESS_OBJECT_MODEL => "business_object_models",
|
|
16
|
+
ExpressSchema::Type::CORE_MODEL => "core_model",
|
|
17
|
+
ExpressSchema::Type::STANDALONE => ".",
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
12
20
|
attr_reader :schemas, :output_path, :options
|
|
13
21
|
|
|
14
22
|
def initialize(schemas:, output_path:, options: {})
|
|
@@ -43,52 +51,32 @@ module Suma
|
|
|
43
51
|
end
|
|
44
52
|
|
|
45
53
|
def export_single_schema(schema)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
is_standalone_file = schema.is_a?(ExportStandaloneSchema)
|
|
49
|
-
schema_output_path = determine_output_path(schema, is_standalone_file)
|
|
54
|
+
is_standalone = !schema.is_a?(Expressir::SchemaManifestEntry)
|
|
55
|
+
schema_output_path = determine_output_path(schema, is_standalone)
|
|
50
56
|
|
|
51
57
|
express_schema = ExpressSchema.new(
|
|
52
58
|
id: schema.id,
|
|
53
59
|
path: schema.path.to_s,
|
|
54
60
|
output_path: schema_output_path,
|
|
55
|
-
is_standalone_file:
|
|
61
|
+
is_standalone_file: is_standalone,
|
|
56
62
|
)
|
|
57
63
|
|
|
58
64
|
express_schema.save_exp(with_annotations: options[:annotations])
|
|
59
65
|
end
|
|
60
66
|
|
|
61
|
-
def determine_output_path(schema,
|
|
62
|
-
if
|
|
63
|
-
# For standalone files, output directly to the root
|
|
67
|
+
def determine_output_path(schema, is_standalone)
|
|
68
|
+
if is_standalone
|
|
64
69
|
output_path.to_s
|
|
65
70
|
else
|
|
66
|
-
# For manifest schemas, preserve directory structure
|
|
67
71
|
category = categorize_schema(schema)
|
|
68
72
|
output_path.join(category).to_s
|
|
69
73
|
end
|
|
70
74
|
end
|
|
71
75
|
|
|
72
|
-
# rubocop:disable Metrics/MethodLength
|
|
73
76
|
def categorize_schema(schema)
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
# Check if this is from a manifest structure or a standalone EXPRESS file
|
|
77
|
-
case path
|
|
78
|
-
when %r{/resources/}
|
|
79
|
-
"resources"
|
|
80
|
-
when %r{/modules/}
|
|
81
|
-
"modules"
|
|
82
|
-
when %r{/business_object_models/}
|
|
83
|
-
"business_object_models"
|
|
84
|
-
when %r{/core_model/}
|
|
85
|
-
"core_model"
|
|
86
|
-
else
|
|
87
|
-
# standalone EXPRESS file not from a manifest structure
|
|
88
|
-
"standalone"
|
|
89
|
-
end
|
|
77
|
+
type = ExpressSchema::Type.classify(id: schema.id, path: schema.path)
|
|
78
|
+
CATEGORY_MAP.fetch(type, "standalone")
|
|
90
79
|
end
|
|
91
|
-
# rubocop:enable Metrics/MethodLength
|
|
92
80
|
|
|
93
81
|
# rubocop:disable Metrics/MethodLength
|
|
94
82
|
def create_zip_archive
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "expressir"
|
|
4
|
+
|
|
5
|
+
module Suma
|
|
6
|
+
# Pre-built index for O(1) schema and element lookup.
|
|
7
|
+
# Build once from a parsed repo, then query by name.
|
|
8
|
+
class SchemaIndex
|
|
9
|
+
def initialize(repo)
|
|
10
|
+
@schemas_by_name = {}
|
|
11
|
+
@elements_by_schema = {}
|
|
12
|
+
|
|
13
|
+
repo.schemas.each do |schema|
|
|
14
|
+
key = schema.id.downcase
|
|
15
|
+
@schemas_by_name[key] = schema
|
|
16
|
+
@elements_by_schema[key] = build_element_index(schema)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def find_schema(name)
|
|
21
|
+
@schemas_by_name[name.downcase]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def find_element(schema_name, element_name)
|
|
25
|
+
elements = @elements_by_schema[schema_name.downcase]
|
|
26
|
+
elements&.[](element_name.downcase)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def build_element_index(schema)
|
|
32
|
+
index = {}
|
|
33
|
+
|
|
34
|
+
element_collections(schema).each do |collection|
|
|
35
|
+
collection&.each { |e| index[e.id.downcase] = e }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
index
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def element_collections(schema)
|
|
42
|
+
[
|
|
43
|
+
schema.entities,
|
|
44
|
+
schema.types,
|
|
45
|
+
schema.constants,
|
|
46
|
+
schema.functions,
|
|
47
|
+
schema.rules,
|
|
48
|
+
schema.procedures,
|
|
49
|
+
schema.subtype_constraints,
|
|
50
|
+
]
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require_relative "utils"
|
|
6
|
+
|
|
7
|
+
module Suma
|
|
8
|
+
class SchemaManifestGenerator
|
|
9
|
+
YAML_FILE_EXTENSIONS = [".yaml", ".yml"].freeze
|
|
10
|
+
|
|
11
|
+
def initialize(metanorma_manifest_file, schema_manifest_file, exclude_paths: nil)
|
|
12
|
+
@metanorma_manifest_file = File.expand_path(metanorma_manifest_file)
|
|
13
|
+
@schema_manifest_file = schema_manifest_file
|
|
14
|
+
@exclude_paths = exclude_paths
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def generate
|
|
18
|
+
validate_inputs
|
|
19
|
+
metanorma_data = load_yaml(@metanorma_manifest_file)
|
|
20
|
+
collection_files = metanorma_data["metanorma"]["source"]["files"]
|
|
21
|
+
manifest_files = load_manifest_files(collection_files)
|
|
22
|
+
all_schemas = load_project_schemas(manifest_files)
|
|
23
|
+
all_schemas["schemas"] = all_schemas["schemas"].sort.to_h
|
|
24
|
+
write_output(all_schemas)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def validate_inputs
|
|
30
|
+
raise Errno::ENOENT, "Specified file `#{@metanorma_manifest_file}` not found." unless File.exist?(@metanorma_manifest_file)
|
|
31
|
+
|
|
32
|
+
raise ArgumentError, "Specified path `#{@metanorma_manifest_file}` is not a file." unless File.file?(@metanorma_manifest_file)
|
|
33
|
+
|
|
34
|
+
[@metanorma_manifest_file, @schema_manifest_file].each do |file|
|
|
35
|
+
unless YAML_FILE_EXTENSIONS.include?(File.extname(file))
|
|
36
|
+
raise ArgumentError, "Specified file `#{file}` is not a YAML file."
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def load_yaml(file_path)
|
|
42
|
+
YAML.safe_load(File.read(file_path, encoding: "UTF-8"), aliases: true)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def load_manifest_files(collection_files)
|
|
46
|
+
collection_files.map do |c|
|
|
47
|
+
collection_data = load_yaml(c)
|
|
48
|
+
collection_data["manifest"]["docref"].map { |docref| docref["file"] }
|
|
49
|
+
end.flatten
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def load_project_schemas(manifest_files)
|
|
53
|
+
all_schemas = { "schemas" => {} }
|
|
54
|
+
|
|
55
|
+
manifest_files.each do |file|
|
|
56
|
+
schemas_file_path = File.expand_path(file.gsub("collection.yml", "schemas.yaml"))
|
|
57
|
+
|
|
58
|
+
unless File.exist?(schemas_file_path)
|
|
59
|
+
Utils.log "Schemas file not found: #{schemas_file_path}"
|
|
60
|
+
next
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
schemas_data = load_yaml(schemas_file_path)
|
|
64
|
+
|
|
65
|
+
if schemas_data["schemas"]
|
|
66
|
+
schemas_data["schemas"] = fix_path(schemas_data, schemas_file_path)
|
|
67
|
+
all_schemas["schemas"].merge!(schemas_data["schemas"])
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if @exclude_paths
|
|
71
|
+
all_schemas["schemas"].delete_if do |_key, value|
|
|
72
|
+
value["path"].match?(
|
|
73
|
+
Regexp.new(@exclude_paths.gsub("*", "(.*){1,999}")),
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
all_schemas
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fix_path(schemas_data, schemas_file_path)
|
|
83
|
+
schema_manifest_path = File.expand_path(@schema_manifest_file, Dir.pwd)
|
|
84
|
+
|
|
85
|
+
schemas_data["schemas"].each do |key, value|
|
|
86
|
+
path_in_schema = File.expand_path(value["path"], File.dirname(schemas_file_path))
|
|
87
|
+
|
|
88
|
+
fixed_path = Pathname.new(path_in_schema).relative_path_from(
|
|
89
|
+
Pathname.new(File.dirname(schema_manifest_path)),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
{ key => value.merge!("path" => fixed_path.to_s) }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
schemas_data["schemas"]
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def write_output(all_schemas)
|
|
99
|
+
output_path = File.expand_path(@schema_manifest_file)
|
|
100
|
+
Utils.log "Writing the Schemas YAML file to #{output_path}..."
|
|
101
|
+
File.write(output_path, all_schemas.to_yaml)
|
|
102
|
+
Utils.log "Writing the Schemas YAML file to #{output_path}...Done"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "report"
|
|
4
|
+
|
|
5
|
+
module Suma
|
|
6
|
+
module SvgQuality
|
|
7
|
+
# Batch report wrapping multiple SVG quality reports
|
|
8
|
+
class BatchReport
|
|
9
|
+
attr_reader :reports
|
|
10
|
+
|
|
11
|
+
def initialize(reports)
|
|
12
|
+
@reports = reports
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def total_files
|
|
16
|
+
@reports.size
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def successful
|
|
20
|
+
@reports.count(&:valid?)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def failed
|
|
24
|
+
total_files - successful
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def avg_quality_score
|
|
28
|
+
return 0 if @reports.empty?
|
|
29
|
+
|
|
30
|
+
@reports.sum(&:quality_score).to_f / total_files
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def total_errors
|
|
34
|
+
@reports.sum(&:error_count)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def avg_error_count
|
|
38
|
+
return 0 if @reports.empty?
|
|
39
|
+
|
|
40
|
+
total_errors.to_f / total_files
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def quality_distribution
|
|
44
|
+
dist = Hash.new(0)
|
|
45
|
+
@reports.each do |r|
|
|
46
|
+
dist[r.quality_tier[:name].to_s] += 1
|
|
47
|
+
end
|
|
48
|
+
dist
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def sort_by_quality
|
|
52
|
+
self.class.new(@reports.sort_by(&:quality_score))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def sort_by_errors
|
|
56
|
+
self.class.new(@reports.sort_by { |r| -r.error_count })
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def limit(count)
|
|
60
|
+
return self if count.nil?
|
|
61
|
+
|
|
62
|
+
self.class.new(@reports.first(count))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def filter_by_min_errors(min)
|
|
66
|
+
return self if min.nil?
|
|
67
|
+
|
|
68
|
+
self.class.new(@reports.select { |r| r.error_count >= min })
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def to_json(*_args)
|
|
72
|
+
JSON.pretty_generate(@reports.map(&:to_h))
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def to_yaml
|
|
76
|
+
YAML.dump(@reports.map(&:to_h))
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Suma
|
|
4
|
+
module SvgQuality
|
|
5
|
+
module Formatters
|
|
6
|
+
# JSON output formatter
|
|
7
|
+
class JsonFormatter
|
|
8
|
+
def initialize(batch_report, output: nil)
|
|
9
|
+
@batch_report = batch_report
|
|
10
|
+
@output = output
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def format
|
|
14
|
+
write_output(@batch_report.to_json)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def write_output(content)
|
|
20
|
+
if @output
|
|
21
|
+
File.write(@output, content)
|
|
22
|
+
"[suma] Results written to #{@output}"
|
|
23
|
+
else
|
|
24
|
+
content
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Suma
|
|
6
|
+
module SvgQuality
|
|
7
|
+
module Formatters
|
|
8
|
+
# Terminal output formatter with ASCII art and emojis
|
|
9
|
+
class TerminalFormatter
|
|
10
|
+
BORDER = "─"
|
|
11
|
+
BOX_WIDTH = 80
|
|
12
|
+
|
|
13
|
+
def initialize(batch_report, output: nil, sort: :quality)
|
|
14
|
+
@batch_report = batch_report
|
|
15
|
+
@output = output
|
|
16
|
+
@sort = sort.to_sym
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def format
|
|
20
|
+
output_content = [
|
|
21
|
+
header,
|
|
22
|
+
"",
|
|
23
|
+
summary_section,
|
|
24
|
+
"",
|
|
25
|
+
distribution_section,
|
|
26
|
+
"",
|
|
27
|
+
files_by_tier_section,
|
|
28
|
+
"",
|
|
29
|
+
footer,
|
|
30
|
+
].join("\n")
|
|
31
|
+
|
|
32
|
+
write_output(output_content)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
attr_reader :batch_report
|
|
38
|
+
|
|
39
|
+
def header
|
|
40
|
+
sort_label = case @sort
|
|
41
|
+
when :errors then "error count (most first)"
|
|
42
|
+
else "quality score (lowest first)"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
"╔#{'═' * (BOX_WIDTH - 2)}╗\n" \
|
|
46
|
+
"║ 🔍 SVG Quality Report Sorted by #{sort_label.ljust(33)}║\n" \
|
|
47
|
+
"╚#{'═' * (BOX_WIDTH - 2)}╝"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def summary_section
|
|
51
|
+
lines = []
|
|
52
|
+
lines << " 📊 OVERVIEW"
|
|
53
|
+
lines << ""
|
|
54
|
+
lines << " ● Total Files : #{batch_report.total_files}"
|
|
55
|
+
lines << " ● Valid : #{batch_report.successful} ✅"
|
|
56
|
+
lines << " ● Invalid : #{batch_report.failed} ❌"
|
|
57
|
+
lines << " ● Avg Score : #{batch_report.avg_quality_score.round(1)}/100"
|
|
58
|
+
lines << " ● Total Errors : #{batch_report.total_errors}"
|
|
59
|
+
lines << " ● Avg Errors : #{batch_report.avg_error_count.round(1)}/file"
|
|
60
|
+
lines << ""
|
|
61
|
+
|
|
62
|
+
if (worst = batch_report.reports.first)
|
|
63
|
+
tier = worst.quality_tier
|
|
64
|
+
lines << " 🚨 WORST OFFENDER"
|
|
65
|
+
lines << ""
|
|
66
|
+
lines << " #{tier[:emoji]} #{shorten_path(worst.file_path)}"
|
|
67
|
+
lines << " Score: #{worst.quality_score}/100 | Errors: #{worst.error_count} | #{tier[:name].to_s.upcase}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
lines.join("\n")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def distribution_section
|
|
74
|
+
lines = []
|
|
75
|
+
lines << " 📈 QUALITY DISTRIBUTION"
|
|
76
|
+
lines << ""
|
|
77
|
+
|
|
78
|
+
total = batch_report.total_files
|
|
79
|
+
dist = batch_report.quality_distribution
|
|
80
|
+
|
|
81
|
+
QualityTiers::ALL.each do |tier|
|
|
82
|
+
count = dist[tier[:name].to_s].to_i
|
|
83
|
+
pct = total.positive? ? (count.to_f / total * 100) : 0
|
|
84
|
+
bar_len = (count.to_f / total * 40).round
|
|
85
|
+
bar = bar_len.positive? ? "█" * bar_len : ""
|
|
86
|
+
empty = "░" * (40 - bar_len)
|
|
87
|
+
|
|
88
|
+
lines << " #{tier[:emoji]} #{tier[:name].to_s.upcase.ljust(9)} #{bar}#{empty} #{count.to_s.rjust(4)} (#{sprintf(
|
|
89
|
+
'%.1f', pct
|
|
90
|
+
)}%)"
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
lines.join("\n")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def files_by_tier_section
|
|
97
|
+
lines = []
|
|
98
|
+
|
|
99
|
+
if @sort == :errors
|
|
100
|
+
# When sorting by errors, show flat list (worst offenders first)
|
|
101
|
+
lines << ""
|
|
102
|
+
lines << " 📋 ALL FILES (sorted by error count, worst first)"
|
|
103
|
+
lines << ""
|
|
104
|
+
|
|
105
|
+
batch_report.reports.each do |report|
|
|
106
|
+
lines << format_file_line(report)
|
|
107
|
+
end
|
|
108
|
+
else
|
|
109
|
+
# When sorting by quality, group by tier - iterate CRITICAL first (worst first)
|
|
110
|
+
reports_by_tier = batch_report.reports.group_by do |r|
|
|
111
|
+
r.quality_tier[:name]
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
QualityTiers::ALL.each do |tier|
|
|
115
|
+
tier_reports = reports_by_tier[tier[:name]]
|
|
116
|
+
next unless tier_reports&.any?
|
|
117
|
+
|
|
118
|
+
lines << ""
|
|
119
|
+
lines << " #{tier[:emoji]} #{tier[:name].to_s.upcase} QUALITY (#{tier_reports.size} files)"
|
|
120
|
+
lines << ""
|
|
121
|
+
|
|
122
|
+
tier_reports.each do |report|
|
|
123
|
+
lines << format_file_line(report)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
lines.join("\n")
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def format_file_line(report)
|
|
132
|
+
path = shorten_path(report.file_path)
|
|
133
|
+
score = report.quality_score.to_i.to_s.rjust(3)
|
|
134
|
+
errors = report.error_count.to_s.rjust(5)
|
|
135
|
+
valid_str = report.valid? ? "✓" : "✗"
|
|
136
|
+
|
|
137
|
+
" #{valid_str} #{score}/100 #{errors} errors #{path}"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def shorten_path(path)
|
|
141
|
+
p = Pathname.new(path)
|
|
142
|
+
if p.absolute?
|
|
143
|
+
begin
|
|
144
|
+
p.relative_path_from(Pathname.pwd)
|
|
145
|
+
rescue StandardError
|
|
146
|
+
p
|
|
147
|
+
end
|
|
148
|
+
else
|
|
149
|
+
p
|
|
150
|
+
end.to_s
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def footer
|
|
154
|
+
BORDER * BOX_WIDTH
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def write_output(content)
|
|
158
|
+
if @output
|
|
159
|
+
File.write(@output, content)
|
|
160
|
+
"[suma] Results written to #{@output}"
|
|
161
|
+
else
|
|
162
|
+
content
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Suma
|
|
6
|
+
module SvgQuality
|
|
7
|
+
module Formatters
|
|
8
|
+
# YAML output formatter
|
|
9
|
+
class YamlFormatter
|
|
10
|
+
def initialize(batch_report, output: nil)
|
|
11
|
+
@batch_report = batch_report
|
|
12
|
+
@output = output
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def format
|
|
16
|
+
write_output(@batch_report.to_yaml)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def write_output(content)
|
|
22
|
+
if @output
|
|
23
|
+
File.write(@output, content)
|
|
24
|
+
"[suma] Results written to #{@output}"
|
|
25
|
+
else
|
|
26
|
+
content
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|