suma 0.3.0 → 0.5.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.
@@ -1,12 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "expressir"
4
+ require "suma/link_validation"
4
5
 
5
6
  module Suma
6
7
  LinkValidationResult = Struct.new(:file, :line, :link, :reason,
7
8
  keyword_init: true)
8
9
 
9
10
  class LinkValidator
11
+ autoload :Step, "suma/link_validator/step"
12
+
10
13
  def initialize(index)
11
14
  @index = index
12
15
  end
@@ -28,7 +31,7 @@ module Suma
28
31
  content = File.read(file)
29
32
  index = {}
30
33
  content.lines.each_with_index do |line, idx|
31
- line.scan(/<<express:([^,>]+)(?:,[^>]+)?>>/).flatten.each do |link|
34
+ line.scan(LinkValidation::EXPRESS_LINK_PATTERN).flatten.each do |link|
32
35
  index[link] ||= idx
33
36
  end
34
37
  end
@@ -99,113 +102,34 @@ module Suma
99
102
  end
100
103
 
101
104
  def validate_deep_path(schema, element, path_parts, file, line_idx,
102
- full_link)
105
+ full_link)
103
106
  current = element
104
107
  current_path = "#{schema.id}.#{element.id}"
108
+ context = Step::Context.new(file: file, line: line_idx, link: full_link,
109
+ schema: schema, path: current_path)
105
110
 
106
111
  path_parts.each do |part|
107
- case current
108
- when Expressir::Model::Declarations::Entity
109
- attribute = current.attributes&.find do |a|
110
- a.id.downcase == part.downcase
111
- end
112
-
113
- unless attribute
114
- return LinkValidationResult.new(
115
- file: file,
116
- line: line_idx + 1,
117
- link: full_link,
118
- reason: "Attribute '#{part}' not found in entity '#{current_path}'",
119
- )
120
- end
121
-
122
- current = attribute
123
- current_path += ".#{part}"
124
-
125
- when Expressir::Model::Declarations::Type
126
- underlying = current.underlying_type
127
-
128
- if underlying.is_a?(Expressir::Model::DataTypes::Enumeration)
129
- enum_value = underlying.items.find do |e|
130
- e.id.downcase == part.downcase
131
- end
132
-
133
- unless enum_value
134
- return LinkValidationResult.new(
135
- file: file,
136
- line: line_idx + 1,
137
- link: full_link,
138
- reason: "Enumeration value '#{part}' not found in type '#{current_path}'",
139
- )
140
- end
141
-
142
- current = enum_value
143
- current_path += ".#{part}"
144
-
145
- elsif underlying
146
- base_type = find_base_type(schema, underlying)
147
-
148
- unless base_type
149
- return LinkValidationResult.new(
150
- file: file,
151
- line: line_idx + 1,
152
- link: full_link,
153
- reason: "Base type not found for '#{current_path}'",
154
- )
155
- end
156
-
157
- current = base_type
158
-
159
- else
160
- return LinkValidationResult.new(
161
- file: file,
162
- line: line_idx + 1,
163
- link: full_link,
164
- reason: "Cannot navigate deeper from type '#{current_path}'",
165
- )
166
- end
112
+ step = Step.for(current)
113
+ return unsupported_step_failure(context) unless step
167
114
 
168
- else
169
- return LinkValidationResult.new(
170
- file: file,
171
- line: line_idx + 1,
172
- link: full_link,
173
- reason: "Cannot navigate deeper from '#{current_path}'",
174
- )
175
- end
115
+ outcome = step.new.navigate(current, part, context)
116
+ return outcome if outcome.is_a?(LinkValidationResult)
117
+
118
+ current = outcome
119
+ current_path += ".#{part}"
120
+ context.path = current_path
176
121
  end
177
122
 
178
123
  nil
179
124
  end
180
125
 
181
- def find_base_type(schema, type_ref)
182
- return nil if %w[INTEGER REAL STRING BOOLEAN NUMBER BINARY
183
- LOGICAL].include?(type_ref.to_s.upcase)
184
-
185
- if type_ref.is_a?(String)
186
- find_schema_element(schema, type_ref)
187
- elsif type_ref.is_a?(Expressir::Model::ModelElement)
188
- type_ref
189
- end
190
- end
191
-
192
- def find_schema_element(schema, element_name)
193
- [
194
- schema.entities,
195
- schema.types,
196
- schema.constants,
197
- schema.functions,
198
- schema.rules,
199
- schema.procedures,
200
- schema.subtype_constraints,
201
- ].each do |collection|
202
- element = collection&.find do |e|
203
- e.id.downcase == element_name.downcase
204
- end
205
- return element if element
206
- end
207
-
208
- nil
126
+ def unsupported_step_failure(context)
127
+ LinkValidationResult.new(
128
+ file: context.file,
129
+ line: context.line + 1,
130
+ link: context.link,
131
+ reason: "Cannot navigate deeper from '#{context.path}'",
132
+ )
209
133
  end
210
134
  end
211
135
  end
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "glossarist"
4
+
5
+ module Suma
6
+ # Cleans raw EXPRESS entity remarks into a list of
7
+ # +Glossarist::V3::DetailedDefinition+ notes.
8
+ #
9
+ # Pipeline:
10
+ # 1. trim to first sentence / first complete list
11
+ # 2. convert express xrefs to URN mentions
12
+ # 3. drop redundant "X is a type of {{...}}" opening notes
13
+ # 4. drop notes containing image references or <<...>> blocks
14
+ # 5. drop notes that duplicate the first definition
15
+ #
16
+ # Pure: no I/O, no schema knowledge. The schema domain label and
17
+ # URN composition are injected via +xref_to_mention+ so this module
18
+ # stays independent of +Suma::Urn+ and the surrounding extractor.
19
+ class NoteProcessor
20
+ REDUNDANT_NOTE_REGEX =
21
+ %r{
22
+ ^An? # Starts with "A" or "An"
23
+ \s.*?\sis\sa\stype\sof # Text followed by "is a type of"
24
+ (\sa|\san)? # Optional " a" or " an"
25
+ \s\{\{[^\}]*\}\} # Text in double curly braces
26
+ \s*?\.?$ # Optional whitespace and period at the end
27
+ }x
28
+
29
+ IMAGE_REF_SUBSTRING = "image::"
30
+ DOUBLE_ANGLE_PATTERN = /<<(.*?){1,999}>>/
31
+ SEE_TAIL_PATTERN = /\s+\(see(.*?){1,999}\)/
32
+ XREF_WITH_DISPLAY = /<<express:([\w.]+),([\w. ][\w. ]*)>>/
33
+ XREF_WITHOUT_DISPLAY = /<<express:([\w.]+)>>/
34
+ INLINE_COMMENT_PATTERN = /\n\/\/.*?\n/
35
+ BULLET_LIST_PATTERN = /^\s*[*\-+]\s+/
36
+ NUMBERED_LIST_PATTERN = /^\s*\d+\.\s+/
37
+ CONTINUATION_PLUS = /^\+\s*$/
38
+ CONTINUATION_DASHES = /^--\s*$/
39
+ INDENTED_PATTERN = /^\s{2,}/
40
+ SENTENCE_BREAK_NEWLINE = /\.\n/
41
+ SENTENCE_BREAK_SPACE = /\. /
42
+
43
+ attr_reader :remarks, :definitions, :xref_to_mention
44
+
45
+ def initialize(remarks, definitions:, xref_to_mention:)
46
+ @remarks = remarks
47
+ @definitions = definitions
48
+ @xref_to_mention = xref_to_mention
49
+ end
50
+
51
+ def self.call(remarks, definitions:, xref_to_mention:)
52
+ new(remarks, definitions: definitions, xref_to_mention: xref_to_mention).call
53
+ end
54
+
55
+ def call
56
+ notes = build_initial_notes
57
+ notes = only_keep_first_sentence(notes)
58
+ notes = remove_see_content(notes)
59
+ notes = remove_redundant_note(notes)
60
+ notes = remove_invalid_references(notes)
61
+ compare_with_definitions(notes, definitions)
62
+ end
63
+
64
+ private
65
+
66
+ def build_initial_notes
67
+ trimmed = trim_definition(remarks)
68
+ return [] unless trimmed && !trimmed.empty?
69
+
70
+ [Glossarist::V3::DetailedDefinition.new(content: trimmed)]
71
+ end
72
+
73
+ def trim_definition(definition)
74
+ return nil if definition.nil? || definition.empty?
75
+
76
+ definition_str = array_to_paragraphs(definition)
77
+ return nil if definition_str.empty?
78
+
79
+ paragraphs = definition_str.split("\n\n")
80
+ combine_paragraphs(paragraphs)
81
+ end
82
+
83
+ def array_to_paragraphs(definition)
84
+ definition.is_a?(Array) ? definition.join("\n\n") : definition.to_s
85
+ end
86
+
87
+ def combine_paragraphs(paragraphs)
88
+ combined = first_block(paragraphs)
89
+ combined = "#{combined}\n"
90
+ combined = combined.gsub(INLINE_COMMENT_PATTERN, "\n")
91
+ combined.strip!
92
+ convert_xref(combined)
93
+ end
94
+
95
+ def first_block(paragraphs)
96
+ first_paragraph = paragraphs.first
97
+ return apply_first_sentence_logic(first_paragraph) unless list_header?(paragraphs)
98
+
99
+ complete_list = extract_complete_list(paragraphs, 1)
100
+ "#{first_paragraph}\n\n#{complete_list}"
101
+ end
102
+
103
+ def list_header?(paragraphs)
104
+ paragraphs.length > 1 &&
105
+ paragraphs.first.end_with?(":") &&
106
+ starts_with_list?(paragraphs[1])
107
+ end
108
+
109
+ def apply_first_sentence_logic(paragraph)
110
+ new_content = paragraph
111
+ .split(SENTENCE_BREAK_NEWLINE).first.strip
112
+ .split(SENTENCE_BREAK_SPACE).first.strip
113
+
114
+ new_content.end_with?(".") ? new_content : "#{new_content}."
115
+ end
116
+
117
+ def extract_complete_list(paragraphs, start_index)
118
+ return paragraphs[start_index] if start_index >= paragraphs.length
119
+
120
+ reduce_list_paragraphs(paragraphs, start_index)
121
+ end
122
+
123
+ def reduce_list_paragraphs(paragraphs, start_index)
124
+ combined = paragraphs[start_index].dup
125
+ state = ContinuationState.new(combined.include?("--") && !combined.match?(/--.*--/m))
126
+
127
+ ((start_index + 1)...paragraphs.length).each do |i|
128
+ break unless apply_paragraph_step?(combined, paragraphs[i], state)
129
+ end
130
+
131
+ combined
132
+ end
133
+
134
+ # Returns true if iteration should continue, false to stop.
135
+ def apply_paragraph_step?(combined, para, state)
136
+ case classify_paragraph(para, state)
137
+ when :continuation_toggle
138
+ state.toggle!
139
+ append!(combined, para)
140
+ when :continuation
141
+ append!(combined, para)
142
+ when :list_member
143
+ append!(combined, para)
144
+ state.toggle! if opens_continuation_block?(para)
145
+ else
146
+ return false
147
+ end
148
+ true
149
+ end
150
+
151
+ def append!(combined, para)
152
+ combined << "\n\n#{para}"
153
+ end
154
+
155
+ def opens_continuation_block?(para)
156
+ para.include?("--") && !para.match?(/--.*--/m)
157
+ end
158
+
159
+ def classify_paragraph(para, state)
160
+ return :continuation_toggle if para.match?(CONTINUATION_DASHES) || para.end_with?("--")
161
+ return :continuation if state.in_block?
162
+ return :list_member if starts_with_list?(para) || list_continuation?(para)
163
+
164
+ :stop
165
+ end
166
+
167
+ # Mutable flag tracking whether we are inside a "-- ... --"
168
+ # continuation block while walking paragraphs.
169
+ class ContinuationState
170
+ def initialize(in_block)
171
+ @in_block = in_block
172
+ end
173
+
174
+ def in_block?
175
+ @in_block
176
+ end
177
+
178
+ def toggle!
179
+ @in_block = !@in_block
180
+ end
181
+ end
182
+ private_constant :ContinuationState
183
+
184
+ def list_continuation?(content)
185
+ return false if content.nil? || content.empty?
186
+
187
+ content.match?(CONTINUATION_PLUS) ||
188
+ content.match?(CONTINUATION_DASHES) ||
189
+ content.match?(INDENTED_PATTERN) ||
190
+ content.start_with?("which", "where", "that")
191
+ end
192
+
193
+ def starts_with_list?(content)
194
+ return false if content.nil? || content.empty?
195
+
196
+ content.match?(BULLET_LIST_PATTERN) || content.match?(NUMBERED_LIST_PATTERN)
197
+ end
198
+
199
+ def only_keep_first_sentence(notes)
200
+ notes.each do |note|
201
+ next unless note&.content
202
+ next if preserve_complete_structure?(note.content)
203
+
204
+ new_content = note.content
205
+ .split(SENTENCE_BREAK_NEWLINE).first.strip
206
+ .split(SENTENCE_BREAK_SPACE).first.strip
207
+ note.content = new_content.end_with?(".") ? new_content : "#{new_content}."
208
+ end
209
+ end
210
+
211
+ def preserve_complete_structure?(content)
212
+ return false if content.nil? || content.empty?
213
+
214
+ lines = content.split("\n")
215
+ return false unless first_line_is_header?(lines)
216
+ return false if first_line_has_period?(lines.first.strip)
217
+
218
+ remaining_content = lines[1..].join("\n")
219
+ starts_with_list?(remaining_content.strip)
220
+ end
221
+
222
+ def first_line_is_header?(lines)
223
+ lines.first&.strip&.end_with?(":") && lines.length > 1
224
+ end
225
+
226
+ def first_line_has_period?(first_line)
227
+ first_line.count(".").positive?
228
+ end
229
+
230
+ def remove_see_content(notes)
231
+ notes.each do |note|
232
+ note.content = note.content.gsub(SEE_TAIL_PATTERN, "")
233
+ end
234
+ end
235
+
236
+ def remove_redundant_note(notes)
237
+ notes.reject do |note|
238
+ note.content.match?(REDUNDANT_NOTE_REGEX) &&
239
+ !note.content.include?("\n")
240
+ end
241
+ end
242
+
243
+ def remove_invalid_references(notes)
244
+ notes.reject do |note|
245
+ note.content.include?(IMAGE_REF_SUBSTRING) ||
246
+ note.content.match?(DOUBLE_ANGLE_PATTERN)
247
+ end
248
+ end
249
+
250
+ def compare_with_definitions(notes, definitions)
251
+ return [] if notes&.first&.content == definitions&.first&.content
252
+
253
+ notes
254
+ end
255
+
256
+ def convert_xref(content)
257
+ content
258
+ .gsub(XREF_WITHOUT_DISPLAY) do |_match|
259
+ full_ref = Regexp.last_match(1)
260
+ display = full_ref.split(".").last
261
+ xref_to_mention.call(full_ref, display)
262
+ end
263
+ .gsub(XREF_WITH_DISPLAY) do |_match|
264
+ full_ref = Regexp.last_match(1)
265
+ display = Regexp.last_match(2)
266
+ xref_to_mention.call(full_ref, display)
267
+ end
268
+ end
269
+ end
270
+ end
@@ -40,7 +40,7 @@ module Suma
40
40
  finalize
41
41
 
42
42
  exporter = SchemaExporter.new(
43
- schemas: @config.schemas,
43
+ schemas: schemas.values,
44
44
  output_path: @output_path_schemas,
45
45
  options: { annotations: false },
46
46
  )
@@ -91,7 +91,7 @@ module Suma
91
91
  existing_schema = Expressir::Changes::SchemaChange.from_file(output_path)
92
92
  end
93
93
 
94
- converter = EengineConverter.new(xml_path, schema_name)
94
+ converter = EengineConverter.new(schema_name, File.read(xml_path))
95
95
  change_schema = converter.convert(
96
96
  version: options[:version],
97
97
  existing_change_schema: existing_schema,
@@ -3,8 +3,19 @@
3
3
  require "fileutils"
4
4
 
5
5
  module Suma
6
- # SchemaExporter exports EXPRESS schemas from a manifest
7
- # with configurable options for annotations and ZIP packaging
6
+ # Exports EXPRESS schemas to a directory, with optional ZIP packaging.
7
+ #
8
+ # Pure sink: the exporter accepts already-loaded +Suma::ExpressSchema+
9
+ # instances and writes their content to disk. Construction of those
10
+ # instances (with the right +output_path+ and +is_standalone_file+
11
+ # flags) is the caller's responsibility — the exporter does not
12
+ # reach across the seam to inspect manifest entries or classify
13
+ # schema types itself.
14
+ #
15
+ # This is a deep module: a small interface (one +export+ method, one
16
+ # option hash) backed by save_exp + zip packaging. The CLI and
17
+ # SchemaCollection adapters construct ExpressSchema instances; the
18
+ # exporter never inspects their shape.
8
19
  class SchemaExporter
9
20
  attr_reader :schemas, :output_path, :options
10
21
 
@@ -35,30 +46,7 @@ module Suma
35
46
 
36
47
  def export_to_directory(schemas)
37
48
  schemas.each do |schema|
38
- export_single_schema(schema)
39
- end
40
- end
41
-
42
- def export_single_schema(schema)
43
- is_standalone = !schema.is_a?(Expressir::SchemaManifestEntry)
44
- schema_output_path = determine_output_path(schema, is_standalone)
45
-
46
- express_schema = ExpressSchema.new(
47
- id: schema.id,
48
- path: schema.path.to_s,
49
- output_path: schema_output_path,
50
- is_standalone_file: is_standalone,
51
- )
52
-
53
- express_schema.save_exp(with_annotations: options[:annotations])
54
- end
55
-
56
- def determine_output_path(schema, is_standalone)
57
- if is_standalone
58
- output_path.to_s
59
- else
60
- category = SchemaCategory.for_schema(id: schema.id, path: schema.path)
61
- output_path.join(category.directory).to_s
49
+ schema.save_exp(with_annotations: options[:annotations])
62
50
  end
63
51
  end
64
52
 
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "svg_conform"
4
+
5
+ module Suma
6
+ module SvgQuality
7
+ # Deep module behind the SVG-quality seam: takes paths in, returns
8
+ # +Report+ / +BatchReport+ out. Owns validator construction and
9
+ # per-file result capture. Does not own file discovery, sorting,
10
+ # filtering, or presentation — those stay in the CLI adapter.
11
+ #
12
+ # The progress adapter is injected: pass any object responding to
13
+ # +#call(index, total, report)+. +NullProgress+ is the default
14
+ # no-op; the CLI passes a lambda that writes to +$stderr+.
15
+ class Scanner
16
+ DEFAULT_PROFILE = :metanorma
17
+
18
+ attr_reader :profile, :progress
19
+
20
+ def initialize(profile: DEFAULT_PROFILE,
21
+ progress: NullProgress.new)
22
+ @profile = profile
23
+ @progress = progress
24
+ end
25
+
26
+ def scan(paths)
27
+ validator = build_validator
28
+ reports = paths.each_with_index.map do |path, index|
29
+ scan_one(validator, path, index, paths.size)
30
+ end
31
+ BatchReport.new(reports)
32
+ end
33
+
34
+ def scan_file(path)
35
+ Report.new(path.to_s, build_validator.validate_file(path.to_s,
36
+ profile: profile))
37
+ end
38
+
39
+ # Default no-op progress adapter. Real progress reporters are
40
+ # passed by the caller; this satisfies the same interface so the
41
+ # scanner can be invoked from specs without forcing the
42
+ # dependency.
43
+ class NullProgress
44
+ def call(_index, _total, _report); end
45
+ end
46
+
47
+ private
48
+
49
+ def build_validator
50
+ SvgConform::Validator.new
51
+ end
52
+
53
+ def scan_one(validator, path, index, total)
54
+ result = validator.validate_file(path.to_s, profile: profile)
55
+ report = Report.new(path.to_s, result)
56
+ progress.call(index, total, report)
57
+ report
58
+ end
59
+ end
60
+ end
61
+ end
@@ -6,6 +6,7 @@ module Suma
6
6
  module SvgQuality
7
7
  autoload :Report, "suma/svg_quality/report"
8
8
  autoload :BatchReport, "suma/svg_quality/batch_report"
9
+ autoload :Scanner, "suma/svg_quality/scanner"
9
10
 
10
11
  module QualityTiers
11
12
  CRITICAL = { name: :critical, min_errors: 200, emoji: "💥" }.freeze
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Suma
4
+ # Term-extractor-specific classification of an EXPRESS schema.
5
+ #
6
+ # Bridges ExpressSchema::Type (the canonical classification shared
7
+ # across the codebase) and the Glossarist-specific labels
8
+ # TermExtractor emits: the domain string ("application module" /
9
+ # "resource") that goes into a concept's +domain+ field, and the
10
+ # entity-type URN term used in generated concept definitions.
11
+ #
12
+ # The mapping is data — a frozen Hash keyed by ExpressSchema::Type
13
+ # symbol — so adding a new schema type is a one-line addition to
14
+ # BY_TYPE (open/closed principle). The previous implementation
15
+ # switched on string keys in three separate places; this consolidates
16
+ # them into one source of truth.
17
+ class TermClassification
18
+ attr_reader :type, :domain_label, :entity_term, :entity_display
19
+
20
+ def initialize(type:, domain_label:, entity_term:, entity_display:)
21
+ @type = type
22
+ @domain_label = domain_label
23
+ @entity_term = entity_term
24
+ @entity_display = entity_display
25
+ freeze
26
+ end
27
+
28
+ def domain_for(schema_id)
29
+ "#{domain_label}: #{schema_id}"
30
+ end
31
+
32
+ BY_TYPE = {
33
+ ExpressSchema::Type::RESOURCE => new(
34
+ type: ExpressSchema::Type::RESOURCE,
35
+ domain_label: "resource",
36
+ entity_term: "express-language.entity_data_type",
37
+ entity_display: "entity data type",
38
+ ),
39
+ ExpressSchema::Type::MODULE_ARM => new(
40
+ type: ExpressSchema::Type::MODULE_ARM,
41
+ domain_label: "application module",
42
+ entity_term: "general.application_object",
43
+ entity_display: "application object",
44
+ ),
45
+ ExpressSchema::Type::MODULE_MIM => new(
46
+ type: ExpressSchema::Type::MODULE_MIM,
47
+ domain_label: "application module",
48
+ entity_term: "express-language.entity_data_type",
49
+ entity_display: "entity data type",
50
+ ),
51
+ ExpressSchema::Type::BUSINESS_OBJECT_MODEL => new(
52
+ type: ExpressSchema::Type::BUSINESS_OBJECT_MODEL,
53
+ domain_label: "resource",
54
+ entity_term: "express-language.entity_data_type",
55
+ entity_display: "entity data type",
56
+ ),
57
+ ExpressSchema::Type::CORE_MODEL => new(
58
+ type: ExpressSchema::Type::CORE_MODEL,
59
+ domain_label: "resource",
60
+ entity_term: "express-language.entity_data_type",
61
+ entity_display: "entity data type",
62
+ ),
63
+ ExpressSchema::Type::STANDALONE => new(
64
+ type: ExpressSchema::Type::STANDALONE,
65
+ domain_label: "resource",
66
+ entity_term: "express-language.entity_data_type",
67
+ entity_display: "entity data type",
68
+ ),
69
+ }.freeze
70
+
71
+ def self.for_schema(id:, path:)
72
+ type = ExpressSchema::Type.classify(id: id, path: path)
73
+ BY_TYPE.fetch(type) do |t|
74
+ raise Error, "[suma] no term classification for type #{t.inspect}"
75
+ end
76
+ end
77
+ end
78
+ end