suma 0.5.7 → 0.5.9

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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/nightly-benchmark.yml +25 -0
  3. data/.github/workflows/release-provenance.yml +41 -0
  4. data/.rubocop_todo.yml +84 -0
  5. data/CLAUDE.md +44 -0
  6. data/README.adoc +136 -11
  7. data/Rakefile +13 -1
  8. data/bin/benchmark_corpus +138 -0
  9. data/bin/release_provenance +40 -0
  10. data/lib/suma/build_plan.rb +71 -0
  11. data/lib/suma/change_classification.rb +37 -0
  12. data/lib/suma/change_impact.rb +61 -0
  13. data/lib/suma/change_manifest.rb +147 -0
  14. data/lib/suma/cli/build.rb +23 -0
  15. data/lib/suma/cli/compare.rb +49 -2
  16. data/lib/suma/cli/core.rb +83 -0
  17. data/lib/suma/cli/export.rb +140 -10
  18. data/lib/suma/cli/extract_terms.rb +11 -0
  19. data/lib/suma/cli/generate_register.rb +12 -0
  20. data/lib/suma/cli/resolve.rb +45 -0
  21. data/lib/suma/cli/validate.rb +90 -0
  22. data/lib/suma/cli.rb +1 -0
  23. data/lib/suma/closure_validator.rb +317 -0
  24. data/lib/suma/corpus_graph.rb +141 -0
  25. data/lib/suma/express_schema.rb +22 -28
  26. data/lib/suma/null_cache.rb +4 -0
  27. data/lib/suma/owl_projection.rb +611 -0
  28. data/lib/suma/processor.rb +55 -6
  29. data/lib/suma/provenance.rb +159 -0
  30. data/lib/suma/publication.rb +108 -0
  31. data/lib/suma/register_manifest_generator.rb +76 -8
  32. data/lib/suma/schema_cache.rb +72 -13
  33. data/lib/suma/schema_category.rb +31 -2
  34. data/lib/suma/schema_collection.rb +58 -5
  35. data/lib/suma/schema_exporter.rb +5 -5
  36. data/lib/suma/schema_naming.rb +29 -33
  37. data/lib/suma/schema_profile.rb +134 -0
  38. data/lib/suma/term_classification.rb +9 -2
  39. data/lib/suma/term_extractor.rb +76 -35
  40. data/lib/suma/urn_resolver.rb +368 -0
  41. data/lib/suma/version.rb +1 -1
  42. data/lib/suma/worker_pool.rb +62 -0
  43. data/lib/suma.rb +12 -0
  44. metadata +19 -2
@@ -0,0 +1,317 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "expressir"
4
+ # Expressir::SchemaManifest references Pathname without requiring it
5
+ # (expressir upstream gap); load it before any manifest parsing.
6
+ require "pathname"
7
+
8
+ module Suma
9
+ # S1 referential closure: extracts every EXPRESS-internal reference from
10
+ # a schema collection and resolves it against the {CorpusGraph}.
11
+ #
12
+ # Reference classes covered (one predicate, many extractors):
13
+ # attribute domains, SELECT/AGGREGATE branches, where-rule expressions,
14
+ # subtype constraints, supertype/subtype lists, rule applies-to targets,
15
+ # and inter-schema interfaces.
16
+ #
17
+ # A reference resolves against, in order: the enclosing schema's declared
18
+ # elements, elements of schemas it interfaces to, the enclosing entity's
19
+ # own attributes (for entity-scoped expressions), the enclosing rule's
20
+ # applies-to attributes and variables, and the EXPRESS builtin vocabulary.
21
+ #
22
+ # The predicate is zero-tolerance: any unresolved reference is a
23
+ # violation, and `violations.empty?` is the corpus well-formedness gate.
24
+ class ClosureValidator
25
+ UnresolvedReference = Struct.new(:file, :line, :schema_id, :owner_id,
26
+ :kind, :reference, :reason,
27
+ keyword_init: true)
28
+
29
+ # Attributes that hold resolved links rather than syntactic children.
30
+ # Traversing them would walk the whole resolved graph (and its cycles)
31
+ # instead of the expression/type tree.
32
+ RESOLUTION_LINK_ATTRIBUTES = %i[ref entity].freeze
33
+
34
+ BUILTIN_IDENTIFIERS = %w[
35
+ abs acos asin atan atan2 bexists blength const_e cos exists exp
36
+ format hibound hiindex length lobound loindex log log2 log10 nvl odd
37
+ roles_of sin sizeof sqrt tan typeof usedin value value_in
38
+ value_range value_list_by unique
39
+ self and or xor not in like equal instanceof
40
+ e pi
41
+ ].freeze
42
+
43
+ attr_reader :graph
44
+
45
+ # @param graph [CorpusGraph]
46
+ def initialize(graph)
47
+ @graph = graph
48
+ @source_lines = {}
49
+ end
50
+
51
+ # Validate every schema of the manifest.
52
+ #
53
+ # @param manifest_file [String] schema manifest path
54
+ # @return [Array<UnresolvedReference>]
55
+ def validate_manifest(manifest_file)
56
+ manifest = Expressir::SchemaManifest.from_file(
57
+ File.expand_path(manifest_file),
58
+ )
59
+ manifest.schemas.flat_map do |entry|
60
+ repo = Expressir::Express::Parser.from_file(entry.path.to_s)
61
+ repo.schemas.flat_map do |schema|
62
+ validate_schema(entry.path.to_s, schema)
63
+ end
64
+ end
65
+ end
66
+
67
+ # All unresolved references in one schema.
68
+ #
69
+ # @param file [String] source file path (for diagnostics)
70
+ # @param schema [Expressir::Model::Declarations::Schema]
71
+ # @return [Array<UnresolvedReference>]
72
+ def validate_schema(file, schema)
73
+ violations = []
74
+ scope = SchemaScope.new(schema, graph)
75
+
76
+ schema.interfaces.each do |interface|
77
+ next if graph.schema_exists?(interface.schema.id)
78
+
79
+ violations << unresolved(file, schema.id, schema.id, "interface",
80
+ interface.schema.id,
81
+ "interface target not declared")
82
+ end
83
+
84
+ schema.children.each do |owner|
85
+ violations.concat(declared_element_references(file, schema.id, owner,
86
+ scope))
87
+ end
88
+ violations
89
+ end
90
+
91
+ private
92
+
93
+ # Type- and structure-position references of one declared element.
94
+ def declared_element_references(file, schema_id, owner, scope)
95
+ refs = typed_references(owner)
96
+ refs.filter_map do |(kind, identifier)|
97
+ if resolvable?(identifier, scope)
98
+ nil
99
+ else
100
+ unresolved(
101
+ file, schema_id, owner_id_of(owner), kind, identifier,
102
+ "'#{identifier}' is not declared in this schema, its " \
103
+ "interfaces, or the EXPRESS builtins"
104
+ )
105
+ end
106
+ end
107
+ end
108
+
109
+ # (kind, identifier) pairs from every position of one declaration.
110
+ def typed_references(owner)
111
+ case owner
112
+ when Expressir::Model::Declarations::Entity then entity_references(owner)
113
+ when Expressir::Model::Declarations::Type then type_references(owner)
114
+ when Expressir::Model::Declarations::Rule then rule_references(owner)
115
+ when Expressir::Model::Declarations::Constant
116
+ datatype_references(owner.type, "constant")
117
+ when Expressir::Model::Declarations::Function
118
+ function_references(owner)
119
+ when Expressir::Model::Declarations::SubtypeConstraint
120
+ subtype_constraint_references(owner)
121
+ else []
122
+ end
123
+ end
124
+
125
+ def entity_references(entity)
126
+ refs = (entity.where_rules || []).flat_map do |rule|
127
+ expression_references(rule.expression, "where")
128
+ end
129
+ entity.attributes.each do |attribute|
130
+ refs.concat(datatype_references(attribute.type, "attribute"))
131
+ end
132
+ refs.concat(supertype_references(entity))
133
+ end
134
+
135
+ def type_references(type)
136
+ refs = (type.where_rules || []).flat_map do |rule|
137
+ expression_references(rule.expression, "where")
138
+ end
139
+ refs.concat(datatype_references(type.underlying_type, "underlying"))
140
+ end
141
+
142
+ def rule_references(rule)
143
+ refs = rule.applies_to.map { |ref| ["rule_applies_to", ref.id] }
144
+ refs += (rule.where_rules || []).flat_map do |where|
145
+ expression_references(where.expression, "where")
146
+ end
147
+ refs += (rule.variables || []).flat_map do |variable|
148
+ datatype_references(variable.type, "variable")
149
+ end
150
+ refs
151
+ end
152
+
153
+ def function_references(function)
154
+ refs = (function.parameters || []).flat_map do |parameter|
155
+ datatype_references(parameter.type, "parameter")
156
+ end
157
+ if function.return_type
158
+ refs.concat(datatype_references(function.return_type, "return"))
159
+ end
160
+ refs
161
+ end
162
+
163
+ def subtype_constraint_references(constraint)
164
+ refs = []
165
+ refs << ["subtype_constraint", constraint.entity.id] if constraint.entity
166
+ refs.concat(supertype_references(constraint))
167
+ end
168
+
169
+ def supertype_references(owner)
170
+ refs = []
171
+ (owner.subtype_of || []).each do |ref|
172
+ refs << ["subtype_of", ref.id] if ref.respond_to?(:id) && ref.id
173
+ end
174
+ if owner.respond_to?(:supertype_expression) && owner.supertype_expression
175
+ refs.concat(expression_references(owner.supertype_expression,
176
+ "supertype"))
177
+ end
178
+ refs
179
+ end
180
+
181
+ # (kind, identifier) pairs from a datatype or expression tree.
182
+ def datatype_references(node, kind)
183
+ expression_references(node, kind)
184
+ end
185
+
186
+ def expression_references(node, kind)
187
+ refs = []
188
+ walk(node) do |child|
189
+ refs << [kind, child.id] if simple_reference?(child)
190
+ end
191
+ refs
192
+ end
193
+
194
+ def simple_reference?(node)
195
+ node.is_a?(Expressir::Model::References::SimpleReference) &&
196
+ !node.id.nil?
197
+ end
198
+
199
+ # Depth-first walk over the syntactic tree of a model element.
200
+ # Expressir only implements `children` on some classes, so traversal
201
+ # goes through the lutaml-model attribute definitions: every attribute
202
+ # whose value is a ModelElement (or a collection of them) is a child.
203
+ # The model graph has reference cycles, so visited nodes are tracked
204
+ # by identity.
205
+ def walk(node, seen = {}.compare_by_identity, &block)
206
+ return unless node.is_a?(Expressir::Model::ModelElement)
207
+ return if seen.key?(node)
208
+
209
+ seen[node] = true
210
+ yield node
211
+ each_model_child(node) { |child| walk(child, seen, &block) }
212
+ end
213
+
214
+ def each_model_child(node)
215
+ node.class.attributes.each_key do |name|
216
+ next if RESOLUTION_LINK_ATTRIBUTES.include?(name)
217
+
218
+ value = begin
219
+ node.public_send(name)
220
+ rescue StandardError
221
+ next
222
+ end
223
+ values = value.is_a?(Array) ? value : [value]
224
+ values.each do |child|
225
+ yield child if child.is_a?(Expressir::Model::ModelElement)
226
+ end
227
+ end
228
+ end
229
+
230
+ # A reference resolves when its root identifier is declared in the
231
+ # schema, visible through an interface, or is an EXPRESS builtin.
232
+ def resolvable?(identifier, scope)
233
+ root = identifier.split(".").first.downcase
234
+ return true if BUILTIN_IDENTIFIERS.include?(root)
235
+
236
+ scope.visible?(root)
237
+ end
238
+
239
+ def unresolved(file, schema_id, owner_id, kind, reference, reason)
240
+ UnresolvedReference.new(
241
+ file: file,
242
+ line: approximate_line(file, reference),
243
+ schema_id: schema_id,
244
+ owner_id: owner_id,
245
+ kind: kind,
246
+ reference: reference,
247
+ reason: reason,
248
+ )
249
+ end
250
+
251
+ # Expressir's Ruby model carries no source positions, so diagnostics
252
+ # approximate the line by the first token occurrence in the file.
253
+ def approximate_line(file, identifier)
254
+ lines = (@source_lines[file] ||= read_source(file))
255
+ token = identifier.split(".").first
256
+ pattern = /\b#{Regexp.escape(token)}\b/i
257
+ index = lines.index { |line| line.match?(pattern) }
258
+ index ? index + 1 : nil
259
+ end
260
+
261
+ def read_source(file)
262
+ File.readlines(file)
263
+ rescue StandardError
264
+ []
265
+ end
266
+
267
+ def owner_id_of(owner)
268
+ owner.respond_to?(:id) && owner.id ? owner.id : "(anonymous)"
269
+ end
270
+
271
+ # Names visible for expression resolution inside one schema: declared
272
+ # elements, the attributes of every entity, rule applies-to entity
273
+ # attributes, rule variables, constants, and function parameters.
274
+ # Only declarations are collected — expression contents are the very
275
+ # references being validated and must never enter the scope.
276
+ class SchemaScope
277
+ def initialize(schema, graph)
278
+ @names = {}
279
+ @graph = graph
280
+ @schema_id = schema.id
281
+
282
+ schema.children.each { |declaration| add(declaration) }
283
+ schema.entities.each do |entity|
284
+ entity.attributes.each { |attribute| add(attribute) }
285
+ end
286
+ schema.rules.each do |rule|
287
+ rule.variables.each { |variable| add(variable) }
288
+ rule.applies_to.each do |ref|
289
+ entity = schema.entities.find do |candidate|
290
+ candidate.id&.downcase == ref.id&.downcase
291
+ end
292
+ next unless entity
293
+
294
+ entity.attributes.each { |attribute| add(attribute) }
295
+ end
296
+ end
297
+ schema.constants.each { |constant| add(constant) }
298
+ schema.functions.each do |function|
299
+ (function.parameters || []).each { |parameter| add(parameter) }
300
+ end
301
+ end
302
+
303
+ def visible?(name)
304
+ @names[name.downcase] ||
305
+ @graph.element_visible?(@schema_id, name)
306
+ end
307
+
308
+ private
309
+
310
+ def add(node)
311
+ return unless node.respond_to?(:id) && node.id
312
+
313
+ @names[node.id.downcase] = true
314
+ end
315
+ end
316
+ end
317
+ end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "expressir"
4
+ # Expressir::SchemaManifest references Pathname without requiring it
5
+ # (expressir upstream gap); load it before any manifest parsing.
6
+ require "pathname"
7
+
8
+ module Suma
9
+ # The declared-element graph of a schema collection: which schemas exist,
10
+ # which elements each declares, and which schemas interface to which.
11
+ #
12
+ # This is the resolution substrate for S1 referential closure and, later,
13
+ # change-impact analysis (S3) and dependency-driven builds (S6). Nodes are
14
+ # schemas and their declared elements; edges are USE/REFERENCE interfaces.
15
+ class CorpusGraph
16
+ attr_reader :schema_ids
17
+
18
+ def initialize(repositories)
19
+ @schemas = {}
20
+ @elements = {}
21
+ @interfaces = {}
22
+
23
+ repositories.each do |repo|
24
+ repo.schemas.each { |schema| add_schema(schema) }
25
+ end
26
+ @schema_ids = @schemas.keys
27
+ freeze
28
+ end
29
+
30
+ # Parse every schema in a manifest and build the graph.
31
+ #
32
+ # @param manifest_file [String] path to the schema manifest
33
+ # @return [CorpusGraph]
34
+ def self.from_manifest(manifest_file)
35
+ manifest = Expressir::SchemaManifest.from_file(
36
+ File.expand_path(manifest_file),
37
+ )
38
+ repositories = manifest.schemas.map do |entry|
39
+ Expressir::Express::Parser.from_file(entry.path.to_s)
40
+ end
41
+ new(repositories)
42
+ end
43
+
44
+ # @param id [String]
45
+ # @return [Boolean]
46
+ def schema_exists?(id)
47
+ @schemas.key?(normalize(id))
48
+ end
49
+
50
+ # Does +schema_id+ (or a schema it interfaces to) declare +element_id+?
51
+ # EXPRESS resolves unqualified names against the local schema and
52
+ # implicitly-used schemas.
53
+ #
54
+ # @param schema_id [String]
55
+ # @param element_id [String]
56
+ # @return [Boolean]
57
+ def element_visible?(schema_id, element_id)
58
+ key = normalize(schema_id)
59
+ return false unless @elements.key?(key)
60
+
61
+ return true if @elements[key].key?(normalize(element_id))
62
+
63
+ visible_from_interfaces(key).include?(normalize(element_id))
64
+ end
65
+
66
+ # Schemas that +schema_id+ USEs FROM or REFERENCEs.
67
+ #
68
+ # @param schema_id [String]
69
+ # @return [Array<String>] normalized ids of interface targets
70
+ def interface_targets(schema_id)
71
+ @interfaces[normalize(schema_id)] || []
72
+ end
73
+
74
+ # Transitive interface closure: every schema +schema_id+ depends on,
75
+ # directly or through other schemas.
76
+ #
77
+ # @param schema_id [String]
78
+ # @return [Array<String>] normalized ids (excluding +schema_id+)
79
+ def dependency_closure(schema_id)
80
+ seen = {}
81
+ queue = interface_targets(schema_id).dup
82
+ until queue.empty?
83
+ id = queue.shift
84
+ next if seen.key?(id)
85
+
86
+ seen[id] = true
87
+ queue.concat(interface_targets(id))
88
+ end
89
+ seen.keys
90
+ end
91
+
92
+ # Schemas that transitively depend on +schema_id+ — the reverse of
93
+ # {#dependency_closure}; used by change-impact analysis (S3).
94
+ #
95
+ # @param schema_id [String]
96
+ # @return [Array<String>] normalized ids, sorted
97
+ def dependents_of(schema_id)
98
+ target = normalize(schema_id)
99
+ @schemas.keys
100
+ .reject { |candidate| candidate == target }
101
+ .select do |candidate|
102
+ dependency_closure(candidate).include?(target)
103
+ end
104
+ .sort
105
+ end
106
+
107
+ private
108
+
109
+ def add_schema(schema)
110
+ key = normalize(schema.id)
111
+ @schemas[key] = true
112
+ @elements[key] = declared_elements(schema)
113
+ targets = (schema.interfaces || [])
114
+ .filter_map { |i| i.schema&.id }
115
+ .map { |id| normalize(id) }
116
+ .uniq
117
+ @interfaces[key] = targets
118
+ end
119
+
120
+ def declared_elements(schema)
121
+ index = {}
122
+ schema.children.each do |element|
123
+ if element.respond_to?(:id) && element.id
124
+ index[normalize(element.id)] =
125
+ true
126
+ end
127
+ end
128
+ index
129
+ end
130
+
131
+ def visible_from_interfaces(key)
132
+ (@interfaces[key] || []).flat_map do |target|
133
+ @elements[target]&.keys || []
134
+ end
135
+ end
136
+
137
+ def normalize(id)
138
+ id&.downcase
139
+ end
140
+ end
141
+ end
@@ -7,47 +7,34 @@ module Suma
7
7
  class ExpressSchema
8
8
  module Type
9
9
  RESOURCE = :resource
10
+ APPLICATION_PROTOCOL = :application_protocol
10
11
  MODULE_ARM = :module_arm
11
12
  MODULE_MIM = :module_mim
12
13
  BUSINESS_OBJECT_MODEL = :business_object_model
13
14
  CORE_MODEL = :core_model
14
15
  STANDALONE = :standalone
15
16
 
16
- ID_SUFFIXES = {
17
- "_arm" => :MODULE_ARM,
18
- "_mim" => :MODULE_MIM,
19
- "_bom" => :BUSINESS_OBJECT_MODEL,
20
- }.freeze
21
-
22
- PATH_SEGMENTS = {
23
- "/resources/" => :RESOURCE,
24
- "/modules/" => :MODULE_ARM,
25
- "/core_model/" => :CORE_MODEL,
26
- }.freeze
27
-
28
- def self.classify(id:, path:)
29
- name = id&.downcase || ""
30
-
31
- ID_SUFFIXES.each do |suffix, type|
32
- return const_get(type) if name.end_with?(suffix)
33
- end
34
-
35
- path_str = path.to_s
36
- PATH_SEGMENTS.each do |segment, type|
37
- return const_get(type) if path_str.include?(segment)
38
- end
39
-
40
- STANDALONE
17
+ # Classify a schema under a schema profile (default: ISO 10303).
18
+ #
19
+ # @param id [String, nil] schema identifier
20
+ # @param path [String, Pathname, nil] schema file path
21
+ # @param profile [SchemaProfile, Symbol, String, nil] schema profile
22
+ # @return [Symbol] a Type constant value
23
+ def self.classify(id:, path:, profile: nil)
24
+ SchemaProfile.fetch(profile).classify(id: id, path: path)
41
25
  end
42
26
  end
43
27
 
44
28
  attr_accessor :path, :id, :parsed, :output_path, :is_standalone_file
45
29
 
46
- def initialize(id:, path:, output_path:, is_standalone_file: false)
30
+ def initialize(id:, path:, output_path:, is_standalone_file: false,
31
+ profile: nil, cache: nil)
47
32
  @path = Pathname.new(path).expand_path
48
33
  @id = id
49
34
  @output_path = output_path
50
35
  @is_standalone_file = is_standalone_file
36
+ @profile = profile
37
+ @cache = cache
51
38
  end
52
39
 
53
40
  def type
@@ -57,7 +44,8 @@ module Suma
57
44
  def parsed
58
45
  return @parsed if @parsed
59
46
 
60
- @parsed = Expressir::Express::Parser.from_file(@path.to_s)
47
+ source = File.read(@path.to_s, encoding: "UTF-8")
48
+ @parsed = @cache&.fetch_parsed(source) || parse_and_cache(source)
61
49
  Utils.log "Loaded EXPRESS schema: #{path}"
62
50
  @id = @parsed.schemas.first.id
63
51
  @parsed
@@ -98,8 +86,14 @@ module Suma
98
86
 
99
87
  private
100
88
 
89
+ def parse_and_cache(source)
90
+ repository = Expressir::Express::Parser.from_file(@path.to_s)
91
+ @cache&.store_parsed(source, repository)
92
+ repository
93
+ end
94
+
101
95
  def classify
102
- Type.classify(id: @id, path: @path)
96
+ Type.classify(id: @id, path: @path, profile: @profile)
103
97
  end
104
98
  end
105
99
  end
@@ -7,5 +7,9 @@ module Suma
7
7
  def fetch(*, **) = nil
8
8
 
9
9
  def store(*, **) = nil
10
+
11
+ def fetch_parsed(*) = nil
12
+
13
+ def store_parsed(*, **) = nil
10
14
  end
11
15
  end