suma 0.2.5 → 0.3.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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/rake.yml +3 -0
  3. data/.github/workflows/release.yml +5 -1
  4. data/.gitignore +10 -1
  5. data/.rubocop_todo.yml +237 -28
  6. data/CLAUDE.md +102 -0
  7. data/Gemfile +3 -1
  8. data/README.adoc +188 -1
  9. data/exe/suma +1 -1
  10. data/lib/suma/cli/build.rb +2 -8
  11. data/lib/suma/cli/check_svg_quality.rb +172 -0
  12. data/lib/suma/cli/compare.rb +6 -158
  13. data/lib/suma/cli/convert_jsdai.rb +0 -2
  14. data/lib/suma/cli/core.rb +119 -0
  15. data/lib/suma/cli/export.rb +1 -10
  16. data/lib/suma/cli/extract_terms.rb +10 -654
  17. data/lib/suma/cli/generate_register.rb +34 -0
  18. data/lib/suma/cli/generate_schemas.rb +8 -124
  19. data/lib/suma/cli/reformat.rb +0 -1
  20. data/lib/suma/cli/validate.rb +0 -2
  21. data/lib/suma/cli/validate_links.rb +14 -291
  22. data/lib/suma/cli.rb +12 -102
  23. data/lib/suma/collection_config.rb +0 -2
  24. data/lib/suma/collection_manifest.rb +7 -111
  25. data/lib/suma/eengine/wrapper.rb +0 -1
  26. data/lib/suma/eengine.rb +8 -0
  27. data/lib/suma/express_schema.rb +43 -31
  28. data/lib/suma/jsdai/figure.rb +0 -3
  29. data/lib/suma/jsdai/figure_xml.rb +12 -9
  30. data/lib/suma/jsdai.rb +5 -8
  31. data/lib/suma/link_validator.rb +211 -0
  32. data/lib/suma/manifest_traverser.rb +92 -0
  33. data/lib/suma/processor.rb +76 -105
  34. data/lib/suma/register_manifest_generator.rb +163 -0
  35. data/lib/suma/schema_category.rb +83 -0
  36. data/lib/suma/schema_collection.rb +28 -63
  37. data/lib/suma/schema_comparer.rb +117 -0
  38. data/lib/suma/schema_compiler.rb +86 -0
  39. data/lib/suma/schema_discovery.rb +75 -0
  40. data/lib/suma/schema_exporter.rb +7 -35
  41. data/lib/suma/schema_index.rb +53 -0
  42. data/lib/suma/schema_manifest_generator.rb +113 -0
  43. data/lib/suma/schema_naming.rb +111 -0
  44. data/lib/suma/schema_template/document.rb +141 -0
  45. data/lib/suma/schema_template/plain.rb +46 -0
  46. data/lib/suma/schema_template.rb +19 -0
  47. data/lib/suma/svg_quality/batch_report.rb +78 -0
  48. data/lib/suma/svg_quality/formatters/json_formatter.rb +30 -0
  49. data/lib/suma/svg_quality/formatters/terminal_formatter.rb +168 -0
  50. data/lib/suma/svg_quality/formatters/yaml_formatter.rb +32 -0
  51. data/lib/suma/svg_quality/formatters.rb +12 -0
  52. data/lib/suma/svg_quality/report.rb +52 -0
  53. data/lib/suma/svg_quality.rb +30 -0
  54. data/lib/suma/term_extractor.rb +466 -0
  55. data/lib/suma/urn.rb +61 -0
  56. data/lib/suma/utils.rb +10 -2
  57. data/lib/suma/version.rb +1 -1
  58. data/lib/suma.rb +34 -5
  59. data/suma.gemspec +3 -2
  60. metadata +53 -9
  61. data/lib/suma/export_standalone_schema.rb +0 -14
  62. data/lib/suma/schema_attachment.rb +0 -130
  63. data/lib/suma/schema_document.rb +0 -132
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ module SchemaTemplate
5
+ # Emits an AsciiDoc body with cross-reference anchors for every
6
+ # schema element so other documents can deep-link into the compiled
7
+ # HTML. Only XML is produced — the anchors only resolve against the
8
+ # XML output, not the HTML rendering.
9
+ class Document
10
+ EXTENSIONS = "xml"
11
+
12
+ attr_reader :schema_id
13
+
14
+ def initialize(schema_id)
15
+ @schema_id = schema_id
16
+ end
17
+
18
+ def extensions
19
+ EXTENSIONS
20
+ end
21
+
22
+ def render(path_to_schema_yaml)
23
+ <<~ADOC
24
+ = #{schema_id}
25
+ :lutaml-express-index: schemas; #{path_to_schema_yaml};
26
+ :bare: true
27
+ :mn-document-class: iso
28
+ :mn-output-extensions: #{extensions}
29
+
30
+ [lutaml_express_liquid,schemas,context]
31
+ ----
32
+ {% for schema in context.schemas %}
33
+
34
+ [[#{schema_id}]]
35
+ [%unnumbered,type=express]
36
+ == #{schema_id} #{rendered_anchors}
37
+
38
+ [source%unnumbered]
39
+ --
40
+ {{ schema.formatted }}
41
+ --
42
+ {% endfor %}
43
+ ----
44
+
45
+ ADOC
46
+ end
47
+
48
+ private
49
+
50
+ # The raw anchor block contains Liquid comments and newlines that
51
+ # are illegal on the section header line; strip them before
52
+ # interpolating.
53
+ def rendered_anchors
54
+ schema_anchors.gsub(%r{//[^\r\n]+}, "").gsub(/[\n\r]+/, "").gsub(
55
+ /^[\n\r]/, ""
56
+ )
57
+ end
58
+
59
+ def schema_anchors
60
+ <<~HEREDOC
61
+ // _fund_cons.liquid
62
+ [[#{schema_id}_funds]]
63
+
64
+ // _constants.liquid
65
+ {% if schema.constants.size > 0 %}
66
+ #{bookmark('constants')}
67
+ {% for thing in schema.constants %}
68
+ #{bookmark('{{thing.id}}')}
69
+ {% endfor %}
70
+ {% endif %}
71
+
72
+ // _types.liquid
73
+ {% if schema.types.size > 0 %}
74
+ #{bookmark('types')}
75
+ // _type.liquid
76
+ {% for thing in schema.types %}
77
+ #{bookmark('{{thing.id}}')}
78
+ {% if thing.items.size > 0 %}
79
+ // _type_items.liquid
80
+ #{bookmark('{{thing.id}}.items')}
81
+ {% for item in thing.items %}
82
+ #{bookmark('{{thing.id}}.items.{{item.id}}')}
83
+ {% endfor %}
84
+ {% endif %}
85
+ {% endfor %}
86
+ {% endif %}
87
+
88
+ // _entities.liquid
89
+ {% if schema.entities.size > 0 %}
90
+ #{bookmark('entities')}
91
+ {% for thing in schema.entities %}
92
+ // _entity.liquid
93
+ #{bookmark('{{thing.id}}')}
94
+ {% endfor %}
95
+ {% endif %}
96
+
97
+ // _subtype_constraints.liquid
98
+ {% if schema.subtype_constraints.size > 0 %}
99
+ #{bookmark('subtype_constraints')}
100
+ // _subtype_constraint.liquid
101
+ {% for thing in schema.subtype_constraints %}
102
+ #{bookmark('{{thing.id}}')}
103
+ {% endfor %}
104
+ {% endif %}
105
+
106
+ // _functions.liquid
107
+ {% if schema.functions.size > 0 %}
108
+ #{bookmark('functions')}
109
+ // _function.liquid
110
+ {% for thing in schema.functions %}
111
+ #{bookmark('{{thing.id}}')}
112
+ {% endfor %}
113
+ {% endif %}
114
+
115
+ // _procedures.liquid
116
+ {% if schema.procedures.size > 0 %}
117
+ #{bookmark('procedures')}
118
+ // _procedure.liquid
119
+ {% for thing in schema.procedures %}
120
+ #{bookmark('{{thing.id}}')}
121
+ {% endfor %}
122
+ {% endif %}
123
+
124
+ // _rules.liquid
125
+ {% if schema.rules.size > 0 %}
126
+ #{bookmark('rules')}
127
+ // _rule.liquid
128
+ {% for thing in schema.rules %}
129
+ #{bookmark('{{thing.id}}')}
130
+ {% endfor %}
131
+ {% endif %}
132
+ HEREDOC
133
+ end
134
+
135
+ def bookmark(anchor)
136
+ mangled = anchor.gsub("}}", ' | replace: "\", "-", "-}}')
137
+ "[[#{schema_id}.#{mangled}]]"
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ module SchemaTemplate
5
+ # Emits a plain AsciiDoc body for a single EXPRESS schema, producing
6
+ # both HTML and XML outputs via Metanorma.
7
+ class Plain
8
+ EXTENSIONS = "xml,html"
9
+
10
+ attr_reader :schema_id
11
+
12
+ def initialize(schema_id)
13
+ @schema_id = schema_id
14
+ end
15
+
16
+ def extensions
17
+ EXTENSIONS
18
+ end
19
+
20
+ def render(path_to_schema_yaml)
21
+ <<~ADOC
22
+ = #{schema_id}
23
+ :lutaml-express-index: schemas; #{path_to_schema_yaml};
24
+ :bare: true
25
+ :mn-document-class: iso
26
+ :mn-output-extensions: #{extensions}
27
+
28
+ [lutaml_express_liquid,schemas,context]
29
+ ----
30
+ {% for schema in context.schemas %}
31
+
32
+ [%unnumbered]
33
+ == #{schema_id}
34
+
35
+ [source%unnumbered]
36
+ --
37
+ {{ schema.formatted }}
38
+ --
39
+ {% endfor %}
40
+ ----
41
+
42
+ ADOC
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ # Pure renderers for the AsciiDoc source fed to Metanorma when compiling
5
+ # an EXPRESS schema to HTML/XML.
6
+ #
7
+ # Each template knows how to produce the adoc body for one compilation
8
+ # flavour (plain HTML, or HTML with cross-reference anchors). Templates
9
+ # have no I/O and no knowledge of the underlying ExpressSchema — they
10
+ # only need the schema id, because the rendered adoc is consumed by
11
+ # Liquid inside Metanorma, which fetches the schema by id from the
12
+ # surrounding lutaml-express-index.
13
+ #
14
+ # Composition with the compiler lives in SchemaCompiler.
15
+ module SchemaTemplate
16
+ autoload :Plain, "suma/schema_template/plain"
17
+ autoload :Document, "suma/schema_template/document"
18
+ end
19
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ module SvgQuality
5
+ # Batch report wrapping multiple SVG quality reports
6
+ class BatchReport
7
+ attr_reader :reports
8
+
9
+ def initialize(reports)
10
+ @reports = reports
11
+ end
12
+
13
+ def total_files
14
+ @reports.size
15
+ end
16
+
17
+ def successful
18
+ @reports.count(&:valid?)
19
+ end
20
+
21
+ def failed
22
+ total_files - successful
23
+ end
24
+
25
+ def avg_quality_score
26
+ return 0 if @reports.empty?
27
+
28
+ @reports.sum(&:quality_score).to_f / total_files
29
+ end
30
+
31
+ def total_errors
32
+ @reports.sum(&:error_count)
33
+ end
34
+
35
+ def avg_error_count
36
+ return 0 if @reports.empty?
37
+
38
+ total_errors.to_f / total_files
39
+ end
40
+
41
+ def quality_distribution
42
+ dist = Hash.new(0)
43
+ @reports.each do |r|
44
+ dist[r.quality_tier[:name].to_s] += 1
45
+ end
46
+ dist
47
+ end
48
+
49
+ def sort_by_quality
50
+ self.class.new(@reports.sort_by(&:quality_score))
51
+ end
52
+
53
+ def sort_by_errors
54
+ self.class.new(@reports.sort_by { |r| -r.error_count })
55
+ end
56
+
57
+ def limit(count)
58
+ return self if count.nil?
59
+
60
+ self.class.new(@reports.first(count))
61
+ end
62
+
63
+ def filter_by_min_errors(min)
64
+ return self if min.nil?
65
+
66
+ self.class.new(@reports.select { |r| r.error_count >= min })
67
+ end
68
+
69
+ def to_json(*_args)
70
+ JSON.pretty_generate(@reports.map(&:to_h))
71
+ end
72
+
73
+ def to_yaml
74
+ YAML.dump(@reports.map(&:to_h))
75
+ end
76
+ end
77
+ end
78
+ 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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ module SvgQuality
5
+ module Formatters
6
+ autoload :TerminalFormatter,
7
+ "suma/svg_quality/formatters/terminal_formatter"
8
+ autoload :JsonFormatter, "suma/svg_quality/formatters/json_formatter"
9
+ autoload :YamlFormatter, "suma/svg_quality/formatters/yaml_formatter"
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ module SvgQuality
5
+ # Simple report object wrapping svg_conform ValidationResult
6
+ class Report
7
+ attr_reader :file_path, :error_count, :errors
8
+
9
+ def initialize(file_path, validation_result)
10
+ @file_path = file_path
11
+ @validation_result = validation_result
12
+ @error_count = validation_result&.error_count || 0
13
+ @errors = validation_result&.errors || []
14
+ end
15
+
16
+ def valid?
17
+ @validation_result&.valid? || false
18
+ end
19
+
20
+ def quality_tier
21
+ QualityTiers.for_error_count(@error_count)
22
+ end
23
+
24
+ def quality_score
25
+ return 100 if @error_count.zero?
26
+ return 0 if @error_count >= 200
27
+
28
+ [100 - (@error_count * 0.5), 0].max.round
29
+ end
30
+
31
+ def errors_by_severity
32
+ {
33
+ critical: @errors.count { |e| e.requirement_id =~ /critical/i },
34
+ high: @errors.count { |e| e.requirement_id =~ /high/i },
35
+ medium: @errors.count { |e| e.requirement_id =~ /medium/i },
36
+ low: @errors.count { |e| e.requirement_id =~ /low/i },
37
+ }
38
+ end
39
+
40
+ def to_h
41
+ {
42
+ file_path: @file_path,
43
+ valid: valid?,
44
+ error_count: @error_count,
45
+ quality_score: quality_score,
46
+ quality_tier: quality_tier[:name],
47
+ errors_by_severity: errors_by_severity,
48
+ }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "svg_conform"
4
+
5
+ module Suma
6
+ module SvgQuality
7
+ autoload :Report, "suma/svg_quality/report"
8
+ autoload :BatchReport, "suma/svg_quality/batch_report"
9
+
10
+ module QualityTiers
11
+ CRITICAL = { name: :critical, min_errors: 200, emoji: "💥" }.freeze
12
+ HIGH = { name: :high, min_errors: 100, emoji: "🔴" }.freeze
13
+ MEDIUM = { name: :medium, min_errors: 50, emoji: "⚠️" }.freeze
14
+ LOW = { name: :low, min_errors: 20, emoji: "🔶" }.freeze
15
+ MINOR = { name: :minor, min_errors: 0, emoji: "✅" }.freeze
16
+
17
+ ALL = [CRITICAL, HIGH, MEDIUM, LOW, MINOR].freeze
18
+
19
+ def self.for_error_count(count)
20
+ ALL.find { |tier| count >= tier[:min_errors] } || MINOR
21
+ end
22
+ end
23
+
24
+ class << self
25
+ def validator(profile: :svg_1_2_rfc)
26
+ SvgConform::Validator.new
27
+ end
28
+ end
29
+ end
30
+ end