suma 0.5.9 → 0.5.10

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.
@@ -14,7 +14,7 @@ module Suma
14
14
  desc: "URN for the dataset source " \
15
15
  "(used for section references)"
16
16
  option :profile, type: :string, default: "iso10303",
17
- desc: "Corpus profile (iso10303 or generic)"
17
+ desc: "Corpus profile (iso10303, generic, or ifc)"
18
18
  option :edition, type: :numeric,
19
19
  desc: "Owning edition recorded on each concept"
20
20
  option :language_codes, type: :array,
@@ -18,7 +18,7 @@ module Suma
18
18
  option :owner, type: :string, default: Suma::RegisterManifestGenerator::DEFAULT_OWNER,
19
19
  desc: "Owner of the dataset (e.g. 'ISO/TC 184/SC 4')"
20
20
  option :profile, type: :string, default: "iso10303",
21
- desc: "Corpus profile (iso10303 or generic)"
21
+ desc: "Corpus profile (iso10303, generic, or ifc)"
22
22
  option :year, type: :numeric,
23
23
  desc: "Edition year for the register"
24
24
  option :supersedes, type: :string,
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Suma
6
+ module Cli
7
+ # Ingest translated localized-concept content into extracted concepts.
8
+ class IngestTranslations < Thor
9
+ def self.exit_on_failure?
10
+ true
11
+ end
12
+
13
+ desc "ingest_translations CONCEPTS_DIR TRANSLATIONS_FILE",
14
+ "Overlay translated localized-concept content onto extracted " \
15
+ "Glossarist concepts; fields the translations omit fall back " \
16
+ "to the source-language content"
17
+ option :output, type: :string, aliases: "-o",
18
+ desc: "Write updated concepts here (default: " \
19
+ "update CONCEPTS_DIR in place)"
20
+
21
+ def ingest_translations(concepts_dir, translations_file)
22
+ unless File.file?(translations_file)
23
+ raise Errno::ENOENT, translations_file
24
+ end
25
+
26
+ applied = TranslationIngester.new(
27
+ concepts_dir: concepts_dir,
28
+ translations: translations_file,
29
+ output_dir: options[:output],
30
+ ).ingest
31
+ Utils.log "Applied #{applied.values.sum(&:size)} translation(s) " \
32
+ "to #{applied.size} concept(s)."
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "thor"
4
+
5
+ module Suma
6
+ module Cli
7
+ # Package extracted concepts into a GCR archive for the Vocabulary
8
+ # Browser.
9
+ class PackageGcr < Thor
10
+ def self.exit_on_failure?
11
+ true
12
+ end
13
+
14
+ desc "package_gcr CONCEPTS_DIR",
15
+ "Package extracted Glossarist concepts into a .gcr archive"
16
+ option :register, type: :string, required: true,
17
+ desc: "register.yaml (dataset identity)"
18
+ option :shortname, type: :string, required: true,
19
+ desc: "Dataset id (matches datasets.yml `id`)"
20
+ option :version, type: :string, required: true,
21
+ desc: "Semver of this dataset release"
22
+ option :title, type: :string, required: true,
23
+ desc: "Human-readable dataset title"
24
+ option :description, type: :string, required: true,
25
+ desc: "Dataset description"
26
+ option :created_at, type: :string,
27
+ desc: "Package timestamp (ISO 8601); pin for " \
28
+ "reproducible archives"
29
+ option :output, type: :string, aliases: "-o", required: true,
30
+ desc: "Output directory for the .gcr archives"
31
+
32
+ def package_gcr(concepts_dir)
33
+ GcrPackage.new(
34
+ concepts_dir: concepts_dir,
35
+ register_file: options[:register],
36
+ shortname: options[:shortname],
37
+ version: options[:version],
38
+ title: options[:title],
39
+ description: options[:description],
40
+ created_at: pinned_time,
41
+ ).write(options[:output])
42
+ end
43
+
44
+ private
45
+
46
+ def pinned_time
47
+ options[:created_at] && Time.iso8601(options[:created_at])
48
+ end
49
+ end
50
+ end
51
+ end
@@ -62,6 +62,22 @@ module Suma
62
62
  fail_on_new_drift(*drift.check(path), path)
63
63
  end
64
64
 
65
+ desc "anchors SCHEMA_DOCS_PATH",
66
+ "Validate rendered schema docs: every Expressir cross-reference " \
67
+ "and in-page link resolves to an anchor in its HTML"
68
+ def anchors(schema_docs_path = "schema_docs")
69
+ violations = AnchorValidator.new(schema_docs_path).validate
70
+ if violations.empty?
71
+ puts "All anchors resolve: no dead links."
72
+ return
73
+ end
74
+
75
+ puts violations.map { |v| "#{v.file}: #{v.link} — #{v.reason}" }
76
+ .join("\n")
77
+ raise Thor::Error,
78
+ "Anchor validation failed: #{violations.size} dead link(s)"
79
+ end
80
+
65
81
  private
66
82
 
67
83
  # Print the closure report and raise on any unresolved reference.
data/lib/suma/cli.rb CHANGED
@@ -2,7 +2,8 @@
2
2
 
3
3
  module Suma
4
4
  module Cli
5
- autoload :Core, "suma/cli/core"
5
+ autoload :Core, "suma/cli/core"
6
+ autoload :ArtifactServerCli, "suma/cli/artifact_server_cli"
6
7
  autoload :Build, "suma/cli/build"
7
8
  autoload :CheckSvgQuality, "suma/cli/check_svg_quality"
8
9
  autoload :Compare, "suma/cli/compare"
@@ -11,6 +12,8 @@ module Suma
11
12
  autoload :ExtractTerms, "suma/cli/extract_terms"
12
13
  autoload :GenerateRegister, "suma/cli/generate_register"
13
14
  autoload :GenerateSchemas, "suma/cli/generate_schemas"
15
+ autoload :IngestTranslations, "suma/cli/ingest_translations"
16
+ autoload :PackageGcr, "suma/cli/package_gcr"
14
17
  autoload :Reformat, "suma/cli/reformat"
15
18
  autoload :Resolve, "suma/cli/resolve"
16
19
  autoload :Validate, "suma/cli/validate"
@@ -15,9 +15,11 @@ module Suma
15
15
  # and inter-schema interfaces.
16
16
  #
17
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.
18
+ # elements, elements of schemas it interfaces to (with USE-FROM cascade),
19
+ # the enclosing entity's own and inherited attributes (for entity-scoped
20
+ # expressions), the enclosing rule's applies-to attributes and variables,
21
+ # QUERY-bound variables, and the EXPRESS builtin vocabulary. Attribute
22
+ # accesses (var.attr) are not schema-level references and are excluded.
21
23
  #
22
24
  # The predicate is zero-tolerance: any unresolved reference is a
23
25
  # violation, and `violations.empty?` is the corpus well-formedness gate.
@@ -56,10 +58,18 @@ module Suma
56
58
  manifest = Expressir::SchemaManifest.from_file(
57
59
  File.expand_path(manifest_file),
58
60
  )
59
- manifest.schemas.flat_map do |entry|
60
- repo = Expressir::Express::Parser.from_file(entry.path.to_s)
61
+ parsed = manifest.schemas.map do |entry|
62
+ [entry, Expressir::Express::Parser.from_file(entry.path.to_s)]
63
+ end
64
+ corpus_entities = parsed.flat_map { |_, repo| repo.schemas }
65
+ .flat_map do |schema|
66
+ schema.children.grep(Expressir::Model::Declarations::Entity)
67
+ end
68
+ .to_h { |entity| [entity.id.downcase, entity] }
69
+
70
+ parsed.flat_map do |entry, repo|
61
71
  repo.schemas.flat_map do |schema|
62
- validate_schema(entry.path.to_s, schema)
72
+ validate_schema(entry.path.to_s, schema, corpus_entities)
63
73
  end
64
74
  end
65
75
  end
@@ -68,10 +78,14 @@ module Suma
68
78
  #
69
79
  # @param file [String] source file path (for diagnostics)
70
80
  # @param schema [Expressir::Model::Declarations::Schema]
81
+ # @param corpus_entities [Hash{String=>Entity}] every entity of the
82
+ # corpus by downcased id, for cross-schema attribute inheritance
71
83
  # @return [Array<UnresolvedReference>]
72
- def validate_schema(file, schema)
84
+ def validate_schema(file, schema, corpus_entities = {})
73
85
  violations = []
74
86
  scope = SchemaScope.new(schema, graph)
87
+ entities = schema.children.grep(Expressir::Model::Declarations::Entity)
88
+ .to_h { |entity| [entity.id.downcase, entity] }
75
89
 
76
90
  schema.interfaces.each do |interface|
77
91
  next if graph.schema_exists?(interface.schema.id)
@@ -83,7 +97,8 @@ module Suma
83
97
 
84
98
  schema.children.each do |owner|
85
99
  violations.concat(declared_element_references(file, schema.id, owner,
86
- scope))
100
+ scope, entities,
101
+ corpus_entities))
87
102
  end
88
103
  violations
89
104
  end
@@ -91,8 +106,9 @@ module Suma
91
106
  private
92
107
 
93
108
  # Type- and structure-position references of one declared element.
94
- def declared_element_references(file, schema_id, owner, scope)
95
- refs = typed_references(owner)
109
+ def declared_element_references(file, schema_id, owner, scope, entities,
110
+ corpus_entities)
111
+ refs = typed_references(owner, entities, corpus_entities)
96
112
  refs.filter_map do |(kind, identifier)|
97
113
  if resolvable?(identifier, scope)
98
114
  nil
@@ -107,11 +123,13 @@ module Suma
107
123
  end
108
124
 
109
125
  # (kind, identifier) pairs from every position of one declaration.
110
- def typed_references(owner)
126
+ def typed_references(owner, entities, corpus_entities)
111
127
  case owner
112
- when Expressir::Model::Declarations::Entity then entity_references(owner)
128
+ when Expressir::Model::Declarations::Entity
129
+ entity_references(owner, entities, corpus_entities)
113
130
  when Expressir::Model::Declarations::Type then type_references(owner)
114
- when Expressir::Model::Declarations::Rule then rule_references(owner)
131
+ when Expressir::Model::Declarations::Rule
132
+ rule_references(owner)
115
133
  when Expressir::Model::Declarations::Constant
116
134
  datatype_references(owner.type, "constant")
117
135
  when Expressir::Model::Declarations::Function
@@ -122,9 +140,10 @@ module Suma
122
140
  end
123
141
  end
124
142
 
125
- def entity_references(entity)
143
+ def entity_references(entity, entities, corpus_entities)
144
+ locals = attribute_scope(entity, entities, corpus_entities)
126
145
  refs = (entity.where_rules || []).flat_map do |rule|
127
- expression_references(rule.expression, "where")
146
+ expression_references(rule.expression, "where", locals)
128
147
  end
129
148
  entity.attributes.each do |attribute|
130
149
  refs.concat(datatype_references(attribute.type, "attribute"))
@@ -141,8 +160,11 @@ module Suma
141
160
 
142
161
  def rule_references(rule)
143
162
  refs = rule.applies_to.map { |ref| ["rule_applies_to", ref.id] }
163
+ locals = Array(rule.variables).filter_map do |variable|
164
+ variable.id&.downcase
165
+ end
144
166
  refs += (rule.where_rules || []).flat_map do |where|
145
- expression_references(where.expression, "where")
167
+ expression_references(where.expression, "where", locals)
146
168
  end
147
169
  refs += (rule.variables || []).flat_map do |variable|
148
170
  datatype_references(variable.type, "variable")
@@ -162,8 +184,20 @@ module Suma
162
184
 
163
185
  def subtype_constraint_references(constraint)
164
186
  refs = []
165
- refs << ["subtype_constraint", constraint.entity.id] if constraint.entity
166
- refs.concat(supertype_references(constraint))
187
+ if constraint.applies_to.is_a?(Expressir::Model::References::SimpleReference)
188
+ refs << ["subtype_constraint", constraint.applies_to.id]
189
+ end
190
+ Array(constraint.total_over).each do |ref|
191
+ next unless ref.is_a?(Expressir::Model::References::SimpleReference)
192
+
193
+ refs << ["total_over", ref.id]
194
+ end
195
+ if constraint.supertype_expression
196
+ refs.concat(
197
+ expression_references(constraint.supertype_expression, "supertype"),
198
+ )
199
+ end
200
+ refs
167
201
  end
168
202
 
169
203
  def supertype_references(owner)
@@ -183,14 +217,84 @@ module Suma
183
217
  expression_references(node, kind)
184
218
  end
185
219
 
186
- def expression_references(node, kind)
220
+ def expression_references(node, kind, locals = [])
221
+ scoped = locals + query_variables(node) +
222
+ attribute_access_identifiers(node)
187
223
  refs = []
188
224
  walk(node) do |child|
189
- refs << [kind, child.id] if simple_reference?(child)
225
+ next unless simple_reference?(child)
226
+ next if child.id == "?"
227
+ next if scoped.include?(child.id.to_s.downcase)
228
+
229
+ refs << [kind, child.id]
190
230
  end
191
231
  refs
192
232
  end
193
233
 
234
+ # QUERY expressions bind their variable inside the query; collect every
235
+ # bound variable name in the tree so the walk can skip them.
236
+ def query_variables(node)
237
+ found = []
238
+ walk(node) do |child|
239
+ if child.is_a?(Expressir::Model::Expressions::QueryExpression) &&
240
+ child.id
241
+ found << child.id.to_s.downcase
242
+ end
243
+ end
244
+ found
245
+ end
246
+
247
+ # Attribute accesses (var.attr, SELF\entity.attr) resolve against the
248
+ # base value's type, not the schema — neither the accessed attribute
249
+ # nor the base identifier is a schema-level reference.
250
+ def attribute_access_identifiers(node)
251
+ found = []
252
+ walk(node) do |child|
253
+ next unless child.is_a?(Expressir::Model::References::AttributeReference)
254
+
255
+ collect_access_identifier(found, child.ref)
256
+ collect_access_identifier(found, child.attribute)
257
+ end
258
+ found
259
+ end
260
+
261
+ def collect_access_identifier(found, part)
262
+ return unless part.is_a?(Expressir::Model::References::SimpleReference)
263
+ return unless part.id
264
+
265
+ found << part.id.to_s.downcase
266
+ end
267
+
268
+ # Bare identifiers in an entity's expressions resolve against the
269
+ # entity's own attributes and those inherited through its supertype
270
+ # chain, following subtypes across schemas when the parent entity is
271
+ # in the corpus (name-keyed; EXPRESS entity names are schema-scoped,
272
+ # but cross-schema parents resolve through interface visibility, so
273
+ # the name is the identity the child's source references).
274
+ def attribute_scope(entity, entities, corpus_entities)
275
+ names = []
276
+ queue = [entity]
277
+ seen = {}
278
+ until queue.empty?
279
+ current = queue.shift
280
+ key = current.id.to_s.downcase
281
+ next if seen[key]
282
+
283
+ seen[key] = true
284
+ Array(current.attributes).each do |attribute|
285
+ names << attribute.id.downcase if attribute.id
286
+ end
287
+ supertype_references(current).each do |(kind, name)|
288
+ next unless kind == "subtype_of"
289
+
290
+ parent = entities[name.downcase] ||
291
+ corpus_entities[name.downcase]
292
+ queue << parent if parent
293
+ end
294
+ end
295
+ names
296
+ end
297
+
194
298
  def simple_reference?(node)
195
299
  node.is_a?(Expressir::Model::References::SimpleReference) &&
196
300
  !node.id.nil?
@@ -13,6 +13,12 @@ module Suma
13
13
  # change-impact analysis (S3) and dependency-driven builds (S6). Nodes are
14
14
  # schemas and their declared elements; edges are USE/REFERENCE interfaces.
15
15
  class CorpusGraph
16
+ # One USE/REFERENCE import: the target schema, its kind, and the named
17
+ # items (renames already applied — the visible name is +items+).
18
+ InterfaceEdge = Struct.new(:kind, :target, :items, keyword_init: true)
19
+
20
+ EMPTY_VISIBLE = {}.freeze
21
+
16
22
  attr_reader :schema_ids
17
23
 
18
24
  def initialize(repositories)
@@ -24,6 +30,7 @@ module Suma
24
30
  repo.schemas.each { |schema| add_schema(schema) }
25
31
  end
26
32
  @schema_ids = @schemas.keys
33
+ @visibility = build_visibility
27
34
  freeze
28
35
  end
29
36
 
@@ -47,20 +54,18 @@ module Suma
47
54
  @schemas.key?(normalize(id))
48
55
  end
49
56
 
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.
57
+ # Does +schema_id+ (or the schemas it imports) declare +element_id+?
58
+ # EXPRESS resolves unqualified names against the local schema and the
59
+ # schema's interface visibility, including USE-FROM cascades.
53
60
  #
54
61
  # @param schema_id [String]
55
62
  # @param element_id [String]
56
63
  # @return [Boolean]
57
64
  def element_visible?(schema_id, element_id)
58
65
  key = normalize(schema_id)
59
- return false unless @elements.key?(key)
60
-
61
- return true if @elements[key].key?(normalize(element_id))
66
+ return false unless @visibility.key?(key)
62
67
 
63
- visible_from_interfaces(key).include?(normalize(element_id))
68
+ @visibility[key].key?(normalize(element_id))
64
69
  end
65
70
 
66
71
  # Schemas that +schema_id+ USEs FROM or REFERENCEs.
@@ -68,7 +73,7 @@ module Suma
68
73
  # @param schema_id [String]
69
74
  # @return [Array<String>] normalized ids of interface targets
70
75
  def interface_targets(schema_id)
71
- @interfaces[normalize(schema_id)] || []
76
+ (@interfaces[normalize(schema_id)] || []).map(&:target)
72
77
  end
73
78
 
74
79
  # Transitive interface closure: every schema +schema_id+ depends on,
@@ -110,11 +115,21 @@ module Suma
110
115
  key = normalize(schema.id)
111
116
  @schemas[key] = true
112
117
  @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
+ @interfaces[key] = Array(schema.interfaces).filter_map do |interface|
119
+ build_edge(interface)
120
+ end
121
+ end
122
+
123
+ def build_edge(interface)
124
+ target = interface.schema&.id
125
+ return nil unless target
126
+
127
+ items = Array(interface.items).filter_map do |item|
128
+ item.id || (item.ref.id if item.ref.respond_to?(:id))
129
+ end
130
+ InterfaceEdge.new(kind: interface.kind.to_s,
131
+ target: normalize(target),
132
+ items: items)
118
133
  end
119
134
 
120
135
  def declared_elements(schema)
@@ -128,9 +143,57 @@ module Suma
128
143
  index
129
144
  end
130
145
 
131
- def visible_from_interfaces(key)
132
- (@interfaces[key] || []).flat_map do |target|
133
- @elements[target]&.keys || []
146
+ # EXPRESS interface visibility (ISO 10303-11 §13.3): a schema sees its
147
+ # declarations plus its imports. An unrestricted USE re-exports
148
+ # everything its target makes visible, so visibility cascades through
149
+ # USE chains; a named import exposes only the named (renamed) items;
150
+ # a REFERENCE exposes the target's declarations without cascading.
151
+ #
152
+ # WG12 corpora ship circular USE interfaces, so this is a monotone
153
+ # fixed-point iteration — sound under cycles, and each pass only adds.
154
+ def build_visibility
155
+ visible = {}
156
+ @schemas.each_key { |key| visible[key] = (@elements[key] || {}).dup }
157
+
158
+ changed = true
159
+ while changed
160
+ changed = false
161
+ @schemas.each_key do |key|
162
+ changed ||= apply_visibility_pass(key, visible)
163
+ end
164
+ end
165
+ visible
166
+ end
167
+
168
+ def apply_visibility_pass(key, visible)
169
+ names = visible[key]
170
+ changed = false
171
+ (@interfaces[key] || []).each do |edge|
172
+ next unless @schemas.key?(edge.target)
173
+
174
+ changed ||= import_edge_names(names, edge, visible)
175
+ end
176
+ changed
177
+ end
178
+
179
+ def import_edge_names(names, edge, visible)
180
+ changed = false
181
+ imported_names(edge, visible).each_key do |name|
182
+ next if names.key?(name)
183
+
184
+ names[name] = true
185
+ changed = true
186
+ end
187
+ changed
188
+ end
189
+
190
+ def imported_names(edge, visible)
191
+ if edge.items.any?
192
+ edge.items.to_h { |name| [normalize(name), true] }
193
+ elsif edge.kind == "USE"
194
+ visible[edge.target] || EMPTY_VISIBLE
195
+ else
196
+ @elements[edge.target] || EMPTY_VISIBLE
134
197
  end
135
198
  end
136
199