suma 0.5.9 → 0.5.11
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/.rubocop_todo.yml +27 -0
- data/CLAUDE.md +17 -5
- data/README.adoc +101 -0
- data/lib/suma/anchor_validator.rb +85 -0
- data/lib/suma/artifact_server.rb +102 -0
- data/lib/suma/cli/artifact_server_cli.rb +22 -0
- data/lib/suma/cli/build.rb +20 -3
- data/lib/suma/cli/core.rb +50 -4
- data/lib/suma/cli/export.rb +1 -1
- data/lib/suma/cli/extract_terms.rb +1 -1
- data/lib/suma/cli/generate_register.rb +1 -1
- data/lib/suma/cli/ingest_translations.rb +36 -0
- data/lib/suma/cli/package_gcr.rb +51 -0
- data/lib/suma/cli/validate.rb +16 -0
- data/lib/suma/cli.rb +4 -1
- data/lib/suma/closure_validator.rb +141 -37
- data/lib/suma/corpus_graph.rb +80 -17
- data/lib/suma/gcr_package.rb +230 -0
- data/lib/suma/link_validation_result.rb +8 -0
- data/lib/suma/link_validator.rb +0 -3
- data/lib/suma/output_canonicalizer.rb +107 -0
- data/lib/suma/owl_projection.rb +3 -3
- data/lib/suma/processor.rb +100 -20
- data/lib/suma/provenance.rb +92 -12
- data/lib/suma/schema_collection.rb +1 -0
- data/lib/suma/schema_compiler.rb +79 -13
- data/lib/suma/schema_exporter.rb +12 -0
- data/lib/suma/schema_index.rb +5 -5
- data/lib/suma/schema_manifest_generator.rb +10 -3
- data/lib/suma/schema_profile.rb +12 -0
- data/lib/suma/schema_template/anchors.rb +154 -0
- data/lib/suma/schema_template/document.rb +2 -93
- data/lib/suma/schema_template/plain.rb +10 -6
- data/lib/suma/schema_template.rb +1 -0
- data/lib/suma/site_build.rb +153 -0
- data/lib/suma/staged_collection_builder.rb +146 -10
- data/lib/suma/term_extractor.rb +10 -4
- data/lib/suma/translation_ingester.rb +169 -0
- data/lib/suma/urn_resolver.rb +3 -3
- data/lib/suma/version.rb +1 -1
- data/lib/suma/worker_pool.rb +5 -1
- data/lib/suma.rb +7 -0
- metadata +13 -2
data/lib/suma/corpus_graph.rb
CHANGED
|
@@ -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
|
|
51
|
-
# EXPRESS resolves unqualified names against the local schema and
|
|
52
|
-
#
|
|
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 @
|
|
60
|
-
|
|
61
|
-
return true if @elements[key].key?(normalize(element_id))
|
|
66
|
+
return false unless @visibility.key?(key)
|
|
62
67
|
|
|
63
|
-
|
|
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,16 +115,26 @@ module Suma
|
|
|
110
115
|
key = normalize(schema.id)
|
|
111
116
|
@schemas[key] = true
|
|
112
117
|
@elements[key] = declared_elements(schema)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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)
|
|
121
136
|
index = {}
|
|
122
|
-
schema.children.each do |element|
|
|
137
|
+
Array(schema.children).each do |element|
|
|
123
138
|
if element.respond_to?(:id) && element.id
|
|
124
139
|
index[normalize(element.id)] =
|
|
125
140
|
true
|
|
@@ -128,9 +143,57 @@ module Suma
|
|
|
128
143
|
index
|
|
129
144
|
end
|
|
130
145
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "zip"
|
|
5
|
+
require "glossarist"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
|
|
8
|
+
module Suma
|
|
9
|
+
# Packages extracted Glossarist concepts into a GCR (Glossarist Concept
|
|
10
|
+
# Repository) archive — the sealed distribution format consumed by the
|
|
11
|
+
# Glossarist Vocabulary Browser (docs/gcr-spec.md in concept-browser).
|
|
12
|
+
#
|
|
13
|
+
# Standard variant only: metadata.yaml + register.yaml + concepts/ in the
|
|
14
|
+
# browser's canonical flat format (termid, term, {lang} blocks). The -full
|
|
15
|
+
# variant (SKOS/TBX/JSONL compilations) is out of scope.
|
|
16
|
+
class GcrPackage
|
|
17
|
+
SCHEMA_VERSION = "1.0.0"
|
|
18
|
+
|
|
19
|
+
attr_reader :shortname, :version
|
|
20
|
+
|
|
21
|
+
# @param concepts_dir [String] TermExtractor output directory
|
|
22
|
+
# @param register_file [String] register.yaml (dataset identity)
|
|
23
|
+
# @param shortname [String] dataset id (datasets.yml `id`)
|
|
24
|
+
# @param version [String] semver of this dataset release
|
|
25
|
+
# @param title [String] human-readable dataset title
|
|
26
|
+
# @param description [String] dataset description
|
|
27
|
+
# @param created_at [Time, nil] package timestamp; pinned packages pass
|
|
28
|
+
# a fixed time so the archive is byte-reproducible
|
|
29
|
+
def initialize(concepts_dir:, register_file:, shortname:, version:,
|
|
30
|
+
title:, description:, created_at: nil)
|
|
31
|
+
@concepts_dir = File.expand_path(concepts_dir)
|
|
32
|
+
@register_file = File.expand_path(register_file)
|
|
33
|
+
@shortname = shortname
|
|
34
|
+
@version = version
|
|
35
|
+
@title = title
|
|
36
|
+
@description = description
|
|
37
|
+
@created_at = created_at || Time.now
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Write the versioned and unversioned .gcr archives.
|
|
41
|
+
#
|
|
42
|
+
# @param output_dir [String]
|
|
43
|
+
# @return [Array<String>] written archive paths
|
|
44
|
+
def write(output_dir)
|
|
45
|
+
FileUtils.mkdir_p(output_dir)
|
|
46
|
+
entries = package_entries
|
|
47
|
+
[versioned_name, unversioned_name].map do |name|
|
|
48
|
+
path = File.join(output_dir, name)
|
|
49
|
+
write_zip(path, entries)
|
|
50
|
+
Utils.log "Wrote GCR package: #{path}"
|
|
51
|
+
path
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Canonical concept hashes, in deterministic (termid) order.
|
|
56
|
+
#
|
|
57
|
+
# @return [Array<Hash>]
|
|
58
|
+
def canonical_concepts
|
|
59
|
+
@canonical_concepts ||= concept_documents
|
|
60
|
+
.map { |document| canonicalize(document) }
|
|
61
|
+
.sort_by { |hash| hash["termid"] }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Hash] metadata.yaml content
|
|
65
|
+
def metadata
|
|
66
|
+
register = Glossarist::DatasetRegister.from_file(@register_file)
|
|
67
|
+
{
|
|
68
|
+
"shortname" => @shortname,
|
|
69
|
+
"version" => @version,
|
|
70
|
+
"title" => @title,
|
|
71
|
+
"description" => @description,
|
|
72
|
+
"glossarist_version" => Glossarist::VERSION,
|
|
73
|
+
"created_at" => @created_at.utc.iso8601,
|
|
74
|
+
"created_by" => "suma CLI",
|
|
75
|
+
# Top-level summary keys as consumed by the Glossarist Vocabulary
|
|
76
|
+
# Browser and the Glossarist::GcrMetadata model.
|
|
77
|
+
"concept_count" => statistics["total_concepts"],
|
|
78
|
+
"languages" => statistics["languages"],
|
|
79
|
+
"uri" => register.urn,
|
|
80
|
+
"uri_aliases" => register.urn_aliases,
|
|
81
|
+
"statistics" => statistics,
|
|
82
|
+
"schema_version" => SCHEMA_VERSION,
|
|
83
|
+
}
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def statistics
|
|
87
|
+
languages = canonical_concepts
|
|
88
|
+
.flat_map { |hash| hash.keys - %w[termid term] }
|
|
89
|
+
.uniq.sort
|
|
90
|
+
{
|
|
91
|
+
"total_concepts" => canonical_concepts.length,
|
|
92
|
+
"languages" => languages,
|
|
93
|
+
"concepts_with_definitions" => concepts_with("definition"),
|
|
94
|
+
"concepts_with_sources" => concepts_with("sources"),
|
|
95
|
+
"concepts_by_status" => { "valid" => canonical_concepts.length },
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def concepts_with(key)
|
|
100
|
+
canonical_concepts.count do |hash|
|
|
101
|
+
hash.values.any? { |block| block.is_a?(Hash) && Array(block[key]).any? }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
def concept_documents
|
|
108
|
+
@concept_documents ||= Dir[File.join(@concepts_dir,
|
|
109
|
+
"*.yaml")].map do |path|
|
|
110
|
+
Glossarist::V3::ConceptDocument.from_yamls(File.read(path))
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Convert a V3 ConceptDocument stream to the browser's canonical flat
|
|
115
|
+
# concept hash (termid, term, one block per language).
|
|
116
|
+
def canonicalize(document)
|
|
117
|
+
concept = document.concept
|
|
118
|
+
hash = { "termid" => concept.data.id }
|
|
119
|
+
document.localizations.each do |localized|
|
|
120
|
+
data = localized.data
|
|
121
|
+
language = data.language_code
|
|
122
|
+
hash[language] = language_block(data)
|
|
123
|
+
hash["term"] ||= preferred_designation(data.terms)
|
|
124
|
+
end
|
|
125
|
+
validate_canonical!(hash)
|
|
126
|
+
hash
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def language_block(data)
|
|
130
|
+
block = { "terms" => term_entries(data) }
|
|
131
|
+
definitions = Array(data.definition)
|
|
132
|
+
unless definitions.empty?
|
|
133
|
+
block["definition"] =
|
|
134
|
+
definition_entries(definitions)
|
|
135
|
+
end
|
|
136
|
+
notes = Array(data.notes).map do |note|
|
|
137
|
+
note.respond_to?(:content) ? note.content : note.to_s
|
|
138
|
+
end
|
|
139
|
+
block["notes"] = notes unless notes.empty?
|
|
140
|
+
sources = Array(data.sources).map { |source| source_hash(source) }
|
|
141
|
+
block["sources"] = sources unless sources.empty?
|
|
142
|
+
block["language_code"] = data.language_code
|
|
143
|
+
block
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def term_entries(data)
|
|
147
|
+
Array(data.terms).map do |term|
|
|
148
|
+
{
|
|
149
|
+
"type" => term.type,
|
|
150
|
+
"designation" => term.designation,
|
|
151
|
+
"normative_status" => term.normative_status,
|
|
152
|
+
}
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
def definition_entries(definitions)
|
|
157
|
+
definitions.map { |definition| { "content" => definition.content } }
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def source_hash(source)
|
|
161
|
+
ref = source.origin&.ref
|
|
162
|
+
origin = {}
|
|
163
|
+
if ref
|
|
164
|
+
origin["ref"] =
|
|
165
|
+
{ "source" => ref.source, "version" => ref.version }.compact
|
|
166
|
+
end
|
|
167
|
+
{ "type" => source.type, "origin" => origin }
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def preferred_designation(terms)
|
|
171
|
+
preferred = Array(terms).find do |term|
|
|
172
|
+
term.normative_status == "preferred"
|
|
173
|
+
end
|
|
174
|
+
(preferred || Array(terms).first)&.designation
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def validate_canonical!(hash)
|
|
178
|
+
termid = hash["termid"]
|
|
179
|
+
unless termid.is_a?(String) && !termid.empty?
|
|
180
|
+
raise Error, "concept without termid"
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
return if language_blocks(hash).any? do |block|
|
|
184
|
+
Array(block["terms"]).any?
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
raise Error, "concept #{termid} has no language block with a term"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def language_blocks(hash)
|
|
191
|
+
hash.values.grep(Hash)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def package_entries
|
|
195
|
+
entries = {
|
|
196
|
+
"metadata.yaml" => "#{YAML.dump(metadata)}\n",
|
|
197
|
+
"register.yaml" => File.read(@register_file),
|
|
198
|
+
}
|
|
199
|
+
seen = {}
|
|
200
|
+
canonical_concepts.each do |hash|
|
|
201
|
+
termid = hash["termid"]
|
|
202
|
+
if seen[termid]
|
|
203
|
+
raise Error, "duplicate termid in package: #{termid}"
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
seen[termid] = true
|
|
207
|
+
entries["concepts/#{termid}.yaml"] = YAML.dump(hash)
|
|
208
|
+
end
|
|
209
|
+
entries
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
def versioned_name
|
|
213
|
+
"#{@shortname}-#{@version}.gcr"
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def unversioned_name
|
|
217
|
+
"#{@shortname}.gcr"
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
def write_zip(path, entries)
|
|
221
|
+
buffer = Zip::OutputStream.write_buffer do |stream|
|
|
222
|
+
entries.each do |name, content|
|
|
223
|
+
stream.put_next_entry(name)
|
|
224
|
+
stream.write(content)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
File.binwrite(path, buffer.string)
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
data/lib/suma/link_validator.rb
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "uuidtools"
|
|
5
|
+
|
|
6
|
+
module Suma
|
|
7
|
+
# Removes Metanorma's nondeterminism from collection output.
|
|
8
|
+
#
|
|
9
|
+
# Metanorma assigns a random UUID to every collection manifest node that
|
|
10
|
+
# lacks an identifier (Manifest#identifier's default) and stamps each
|
|
11
|
+
# rendered document with a random 8-letter type marker
|
|
12
|
+
# (collection/xrefprocess). Both leak into the emitted manifest and the XML
|
|
13
|
+
# output, so two identical builds produce different bytes.
|
|
14
|
+
#
|
|
15
|
+
# Manifest identifiers are replaced IN THE MODEL before serialization:
|
|
16
|
+
# Psych's quoting of the resulting strings is value-dependent (a few
|
|
17
|
+
# percent of UUID-looking values get single-quoted), so patching the dumped
|
|
18
|
+
# YAML afterwards would preserve a random quoting decision. Values that
|
|
19
|
+
# appear in the source YAML are preserved.
|
|
20
|
+
class OutputCanonicalizer
|
|
21
|
+
UUID_PATTERN =
|
|
22
|
+
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
|
|
23
|
+
FULL_UUID_PATTERN = /\A#{UUID_PATTERN.source}\z/
|
|
24
|
+
CANONICAL_TYPE = "SUMASUMA"
|
|
25
|
+
NAMESPACE = UUIDTools::UUID.parse("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
|
|
26
|
+
|
|
27
|
+
def initialize(output_directory:, sources: [])
|
|
28
|
+
@output_directory = File.expand_path(output_directory)
|
|
29
|
+
@sources = sources.map { |path| File.expand_path(path) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Replace generated manifest identifiers with deterministic UUIDv5s.
|
|
33
|
+
# Must run before the manifest is serialized and the site key computed.
|
|
34
|
+
def canonicalize_manifest(manifest)
|
|
35
|
+
canonicalize_node(manifest, "collection-manifest")
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Canonicalize the rendered site's root XML documents.
|
|
40
|
+
def canonicalize_documents
|
|
41
|
+
Dir.glob(File.join(@output_directory, "*.xml")).each do |file|
|
|
42
|
+
canonicalize_document(file)
|
|
43
|
+
end
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def canonicalize_node(node, seed)
|
|
50
|
+
return unless node
|
|
51
|
+
|
|
52
|
+
if generated_uuid?(node.identifier)
|
|
53
|
+
node.identifier = uuid_v5("suma-manifest:#{seed}").to_s
|
|
54
|
+
end
|
|
55
|
+
Array(node.entry).each_with_index do |child, index|
|
|
56
|
+
canonicalize_node(child, "#{seed}/#{index}")
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def generated_uuid?(value)
|
|
61
|
+
value.to_s.match?(FULL_UUID_PATTERN) && !preserved.include?(value)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def canonicalize_document(file)
|
|
65
|
+
content = File.read(file)
|
|
66
|
+
canonical = replace_uuids(content, File.basename(file))
|
|
67
|
+
canonical = replace_type(canonical)
|
|
68
|
+
File.write(file, canonical) if canonical != content
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def replace_uuids(content, seed)
|
|
72
|
+
index = 0
|
|
73
|
+
content.gsub(UUID_PATTERN) do |uuid|
|
|
74
|
+
next uuid if preserved.include?(uuid)
|
|
75
|
+
|
|
76
|
+
index += 1
|
|
77
|
+
uuid_v5("suma-document:#{seed}:#{index}").to_s
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def replace_type(content)
|
|
82
|
+
content.sub(/(<metanorma\b[^>]*\btype=")[A-Z]{8}(")/) do
|
|
83
|
+
"#{Regexp.last_match(1)}#{CANONICAL_TYPE}#{Regexp.last_match(2)}"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# UUIDs that occur in the site's source YAML. Identifiers the author
|
|
88
|
+
# wrote must survive; only generated ones are replaced. YAML is read as
|
|
89
|
+
# bytes: an author-supplied file with non-UTF-8 bytes must not fail the
|
|
90
|
+
# build, and the pattern is ASCII-only.
|
|
91
|
+
def preserved
|
|
92
|
+
@preserved ||= yaml_sources.flat_map do |file|
|
|
93
|
+
File.binread(file).scan(UUID_PATTERN)
|
|
94
|
+
end.uniq
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def yaml_sources
|
|
98
|
+
@sources.select do |file|
|
|
99
|
+
file.match?(/\.ya?ml\z/i) && File.file?(file)
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def uuid_v5(name)
|
|
104
|
+
UUIDTools::UUID.sha1_create(NAMESPACE, name)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/suma/owl_projection.rb
CHANGED
|
@@ -167,7 +167,7 @@ module Suma
|
|
|
167
167
|
# the Expressir model and must not shadow declarations).
|
|
168
168
|
def index_elements
|
|
169
169
|
@schemas.each_with_object({}) do |(schema_key, schema), map|
|
|
170
|
-
schema.children.each do |element|
|
|
170
|
+
Array(schema.children).each do |element|
|
|
171
171
|
next unless class_element?(element)
|
|
172
172
|
|
|
173
173
|
map[[schema_key, normalize(element.id)]] = element
|
|
@@ -231,7 +231,7 @@ module Suma
|
|
|
231
231
|
end
|
|
232
232
|
|
|
233
233
|
def project_schema(graph, schema_key, schema)
|
|
234
|
-
schema.children.each do |element|
|
|
234
|
+
Array(schema.children).each do |element|
|
|
235
235
|
case element
|
|
236
236
|
when Expressir::Model::Declarations::Entity
|
|
237
237
|
project_entity(graph, schema_key, element)
|
|
@@ -482,7 +482,7 @@ module Suma
|
|
|
482
482
|
|
|
483
483
|
def validate_materialization(projected)
|
|
484
484
|
projected.flat_map do |schema_key, schema|
|
|
485
|
-
schema.children.filter_map do |element|
|
|
485
|
+
Array(schema.children).filter_map do |element|
|
|
486
486
|
next unless class_element?(element)
|
|
487
487
|
|
|
488
488
|
iri = element_iri(schema_key, element.id)
|