suma 0.2.6 → 0.4.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.
- checksums.yaml +4 -4
- data/.gitignore +7 -1
- data/.rubocop_todo.yml +170 -13
- data/CLAUDE.md +37 -11
- data/Gemfile +3 -3
- data/README.adoc +127 -13
- data/exe/suma +1 -1
- data/lib/suma/cli/build.rb +0 -5
- data/lib/suma/cli/check_svg_quality.rb +82 -97
- data/lib/suma/cli/compare.rb +0 -1
- data/lib/suma/cli/convert_jsdai.rb +0 -2
- data/lib/suma/cli/core.rb +119 -0
- data/lib/suma/cli/export.rb +37 -22
- data/lib/suma/cli/extract_terms.rb +5 -8
- data/lib/suma/cli/generate_register.rb +34 -0
- data/lib/suma/cli/generate_schemas.rb +0 -2
- data/lib/suma/cli/reformat.rb +39 -64
- data/lib/suma/cli/validate.rb +14 -7
- data/lib/suma/cli/validate_links.rb +11 -157
- data/lib/suma/cli.rb +12 -141
- data/lib/suma/collection_config.rb +0 -2
- data/lib/suma/collection_manifest.rb +7 -110
- data/lib/suma/eengine/wrapper.rb +0 -1
- data/lib/suma/eengine.rb +8 -0
- data/lib/suma/express_reformatter.rb +94 -0
- data/lib/suma/express_schema.rb +0 -1
- data/lib/suma/jsdai/figure.rb +0 -3
- data/lib/suma/jsdai.rb +5 -2
- data/lib/suma/link_validation.rb +144 -0
- data/lib/suma/link_validator.rb +17 -8
- data/lib/suma/manifest_traverser.rb +92 -0
- data/lib/suma/processor.rb +5 -8
- data/lib/suma/register_manifest_generator.rb +163 -0
- data/lib/suma/schema_category.rb +83 -0
- data/lib/suma/schema_collection.rb +30 -34
- data/lib/suma/schema_comparer.rb +4 -3
- data/lib/suma/schema_compiler.rb +86 -0
- data/lib/suma/schema_discovery.rb +75 -0
- data/lib/suma/schema_exporter.rb +14 -42
- data/lib/suma/schema_manifest_generator.rb +14 -6
- data/lib/suma/schema_naming.rb +111 -0
- data/lib/suma/schema_template/document.rb +141 -0
- data/lib/suma/schema_template/plain.rb +46 -0
- data/lib/suma/schema_template.rb +19 -0
- data/lib/suma/svg_quality/batch_report.rb +0 -2
- data/lib/suma/svg_quality/formatters.rb +12 -0
- data/lib/suma/svg_quality/scanner.rb +61 -0
- data/lib/suma/svg_quality.rb +4 -1
- data/lib/suma/term_classification.rb +78 -0
- data/lib/suma/term_extractor.rb +125 -81
- data/lib/suma/urn.rb +61 -0
- data/lib/suma/version.rb +1 -1
- data/lib/suma.rb +35 -3
- data/suma.gemspec +1 -1
- metadata +28 -6
- data/lib/suma/schema_attachment.rb +0 -103
- data/lib/suma/schema_document.rb +0 -118
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Suma
|
|
4
|
+
# Reformats EXPRESS source into suma's canonical form: comment blocks
|
|
5
|
+
# (the +(*"...\n*)+ remarks that EXPRESS uses for documentation) are
|
|
6
|
+
# extracted from their inline positions and appended to the end of
|
|
7
|
+
# the file, and excess blank lines are collapsed.
|
|
8
|
+
#
|
|
9
|
+
# Pure content transformation — no I/O, no Thor, no filesystem. The
|
|
10
|
+
# CLI adapter handles reading from and writing to disk.
|
|
11
|
+
#
|
|
12
|
+
# The transform is idempotent: reformatting an already-reformatted
|
|
13
|
+
# document returns +changed?: false+.
|
|
14
|
+
#
|
|
15
|
+
# Comment extraction uses +String#index+ rather than a single m-mode
|
|
16
|
+
# regex. The original +/\(\*"(.*?)\n\*\)/m+ is correct functionally
|
|
17
|
+
# but is polynomial-time on adversarial input (CodeQL
|
|
18
|
+
# +rb/polynomial-redos+). Scanning for the start marker, then for
|
|
19
|
+
# the next terminator, is unambiguously linear.
|
|
20
|
+
module ExpressReformatter
|
|
21
|
+
Result = Struct.new(:content, :changed?, keyword_init: true) do
|
|
22
|
+
def content_or_nil
|
|
23
|
+
changed? ? content : nil
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
COMMENT_START = '(*"'
|
|
28
|
+
COMMENT_END = "\n*)"
|
|
29
|
+
BLANK_RUN_PATTERN = /(\n\n+)/
|
|
30
|
+
NEWLINE_RUN_PATTERN = /(\n+)/
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def call(content)
|
|
35
|
+
comments = extract_comments(content)
|
|
36
|
+
return Result.new(content: content, changed?: false) if comments.empty?
|
|
37
|
+
|
|
38
|
+
without_comments = strip_comments(content)
|
|
39
|
+
new_comments = comments.map { |c| "#{COMMENT_START}#{c}#{COMMENT_END}" }
|
|
40
|
+
.join("\n\n")
|
|
41
|
+
new_content = "#{without_comments}\n\n#{new_comments}\n"
|
|
42
|
+
new_content = new_content.gsub(BLANK_RUN_PATTERN, "\n\n")
|
|
43
|
+
|
|
44
|
+
changed = normalised_compare(content, new_content) != 0
|
|
45
|
+
Result.new(content: new_content, changed?: changed)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def extract_comments(content)
|
|
49
|
+
comments = []
|
|
50
|
+
pos = 0
|
|
51
|
+
while (start_idx = content.index(COMMENT_START, pos))
|
|
52
|
+
end_idx = content.index(COMMENT_END, start_idx + COMMENT_START.length)
|
|
53
|
+
break unless end_idx
|
|
54
|
+
|
|
55
|
+
comments << content[(start_idx + COMMENT_START.length)...end_idx]
|
|
56
|
+
pos = end_idx + COMMENT_END.length
|
|
57
|
+
end
|
|
58
|
+
comments
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def strip_comments(content)
|
|
62
|
+
return content unless content.index(COMMENT_START, 0)
|
|
63
|
+
|
|
64
|
+
stripped = +""
|
|
65
|
+
last_end = 0
|
|
66
|
+
each_comment_range(content) do |range|
|
|
67
|
+
stripped << content[range[:pre_start]...range[:start]]
|
|
68
|
+
last_end = range[:end_exclusive]
|
|
69
|
+
end
|
|
70
|
+
stripped << content[last_end..]
|
|
71
|
+
stripped.force_encoding(content.encoding)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def each_comment_range(content)
|
|
75
|
+
return enum_for(:each_comment_range, content) unless block_given?
|
|
76
|
+
|
|
77
|
+
pos = 0
|
|
78
|
+
while (start_idx = content.index(COMMENT_START, pos))
|
|
79
|
+
end_idx = content.index(COMMENT_END, start_idx + COMMENT_START.length)
|
|
80
|
+
break unless end_idx
|
|
81
|
+
|
|
82
|
+
yield pre_start: pos, start: start_idx,
|
|
83
|
+
end_exclusive: end_idx + COMMENT_END.length
|
|
84
|
+
pos = end_idx + COMMENT_END.length
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def normalised_compare(left, right)
|
|
89
|
+
left.gsub(NEWLINE_RUN_PATTERN, "\n") <=> right.gsub(NEWLINE_RUN_PATTERN, "\n")
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private_class_method :normalised_compare
|
|
93
|
+
end
|
|
94
|
+
end
|
data/lib/suma/express_schema.rb
CHANGED
data/lib/suma/jsdai/figure.rb
CHANGED
data/lib/suma/jsdai.rb
CHANGED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "expressir"
|
|
5
|
+
|
|
6
|
+
module Suma
|
|
7
|
+
# Deep module behind the EXPRESS cross-reference validation pipeline.
|
|
8
|
+
#
|
|
9
|
+
# Interface: paths in, +Result+ out. The CLI is a thin adapter that
|
|
10
|
+
# constructs this object and prints the result.
|
|
11
|
+
#
|
|
12
|
+
# Owns: loading the schemas manifest, discovering + reading .adoc and
|
|
13
|
+
# .exp files, extracting express cross-reference links, loading parsed
|
|
14
|
+
# schemas into a +SchemaIndex+, delegating to +LinkValidator+ for the
|
|
15
|
+
# actual resolution, and writing the summary file.
|
|
16
|
+
#
|
|
17
|
+
# Does not own: presentation (use +LinkValidation.generate_summary+),
|
|
18
|
+
# command-line argument parsing (the CLI adapter does that), or the
|
|
19
|
+
# link-resolution rules themselves (that is +LinkValidator+'s job).
|
|
20
|
+
class LinkValidation
|
|
21
|
+
EXPRESS_LINK_PATTERN = /<<express:([^,>]+)(?:,[^>]+)?>>/
|
|
22
|
+
|
|
23
|
+
attr_reader :schemas_file, :documents_path, :output_file,
|
|
24
|
+
:progress, :logger
|
|
25
|
+
|
|
26
|
+
def initialize(schemas_file:, documents_path:, output_file:,
|
|
27
|
+
progress: NullProgress.new, logger: Utils)
|
|
28
|
+
@schemas_file = Pathname.new(schemas_file).expand_path
|
|
29
|
+
@documents_path = Pathname.new(documents_path).expand_path
|
|
30
|
+
@output_file = output_file && Pathname.new(output_file).expand_path
|
|
31
|
+
@progress = progress
|
|
32
|
+
@logger = logger
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def call
|
|
36
|
+
config = load_schemas_config
|
|
37
|
+
exp_files = collect_schema_paths(config)
|
|
38
|
+
adoc_files = find_adoc_files
|
|
39
|
+
links_by_file = extract_links(adoc_files + exp_files)
|
|
40
|
+
unresolved = validate_links(config, links_by_file)
|
|
41
|
+
result = Result.new(
|
|
42
|
+
adoc_count: adoc_files.size,
|
|
43
|
+
exp_count: exp_files.size,
|
|
44
|
+
total_links: links_by_file.values.sum(&:size),
|
|
45
|
+
unresolved: unresolved,
|
|
46
|
+
)
|
|
47
|
+
write_summary(result) if output_file
|
|
48
|
+
result
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def self.generate_summary(result)
|
|
52
|
+
lines = []
|
|
53
|
+
lines << "Validation complete. Checked #{result.total_links} links."
|
|
54
|
+
if result.success?
|
|
55
|
+
lines << "✅ All links resolved successfully!"
|
|
56
|
+
else
|
|
57
|
+
lines << "❌ Found #{result.unresolved.size} unresolved links:"
|
|
58
|
+
result.unresolved.each { |issue| lines << format_issue(issue) }
|
|
59
|
+
end
|
|
60
|
+
lines.join("\n")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.format_issue(issue)
|
|
64
|
+
"#{issue.file}:#{issue.line} - " \
|
|
65
|
+
"<<express:#{issue.link}>> - #{issue.reason}"
|
|
66
|
+
end
|
|
67
|
+
private_class_method :format_issue
|
|
68
|
+
|
|
69
|
+
# Default no-op progress adapter. Callers that want a real progress
|
|
70
|
+
# bar pass an object responding to +#start(title, total)+ and
|
|
71
|
+
# +#increment+; this satisfies the same interface without forcing
|
|
72
|
+
# the dependency.
|
|
73
|
+
class NullProgress
|
|
74
|
+
def start(_title, _total); end
|
|
75
|
+
|
|
76
|
+
def increment; end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
Result = Struct.new(
|
|
80
|
+
:adoc_count,
|
|
81
|
+
:exp_count,
|
|
82
|
+
:total_links,
|
|
83
|
+
:unresolved,
|
|
84
|
+
keyword_init: true,
|
|
85
|
+
) do
|
|
86
|
+
def success?
|
|
87
|
+
unresolved.empty?
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def load_schemas_config
|
|
94
|
+
Expressir::SchemaManifest.from_yaml(File.read(schemas_file))
|
|
95
|
+
.tap { |c| c.set_initial_path(schemas_file.to_s) }
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
raise Error, "Error loading schemas file: #{e.message}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def collect_schema_paths(schemas_config)
|
|
101
|
+
schemas_config.schemas.filter_map(&:path)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def find_adoc_files
|
|
105
|
+
Dir.glob(documents_path.join("**", "*.adoc").to_s)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def extract_links(files)
|
|
109
|
+
links_by_file = {}
|
|
110
|
+
progress.start("Processing files", files.size)
|
|
111
|
+
files.each do |file|
|
|
112
|
+
links = extract_links_from_file(file)
|
|
113
|
+
links_by_file[file] = links if links&.any?
|
|
114
|
+
end
|
|
115
|
+
links_by_file
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def extract_links_from_file(file)
|
|
119
|
+
progress.increment
|
|
120
|
+
content = File.read(file)
|
|
121
|
+
content.scan(EXPRESS_LINK_PATTERN).flatten.uniq
|
|
122
|
+
rescue StandardError => e
|
|
123
|
+
logger.log "Warning: Could not read file #{file}: #{e.message}"
|
|
124
|
+
nil
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def validate_links(schemas_config, links_by_file)
|
|
128
|
+
paths_by_id = schemas_config.schemas.to_h { |s| [s.id, s.path] }
|
|
129
|
+
progress.start("Loading schemas", paths_by_id.size)
|
|
130
|
+
repo = Expressir::Express::Parser.from_files(paths_by_id.values) do |*_args|
|
|
131
|
+
progress.increment
|
|
132
|
+
end
|
|
133
|
+
index = SchemaIndex.new(repo)
|
|
134
|
+
LinkValidator.new(index).validate(links_by_file)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def write_summary(result)
|
|
138
|
+
FileUtils.mkdir_p(output_file.dirname)
|
|
139
|
+
File.write(output_file, self.class.generate_summary(result))
|
|
140
|
+
rescue StandardError => e
|
|
141
|
+
logger.log "Error writing to output file: #{e.message}"
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
data/lib/suma/link_validator.rb
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "schema_index"
|
|
4
3
|
require "expressir"
|
|
4
|
+
require "suma/link_validation"
|
|
5
5
|
|
|
6
6
|
module Suma
|
|
7
|
-
LinkValidationResult = Struct.new(:file, :line, :link, :reason,
|
|
7
|
+
LinkValidationResult = Struct.new(:file, :line, :link, :reason,
|
|
8
|
+
keyword_init: true)
|
|
8
9
|
|
|
9
10
|
class LinkValidator
|
|
10
11
|
def initialize(index)
|
|
@@ -28,7 +29,7 @@ module Suma
|
|
|
28
29
|
content = File.read(file)
|
|
29
30
|
index = {}
|
|
30
31
|
content.lines.each_with_index do |line, idx|
|
|
31
|
-
line.scan(
|
|
32
|
+
line.scan(LinkValidation::EXPRESS_LINK_PATTERN).flatten.each do |link|
|
|
32
33
|
index[link] ||= idx
|
|
33
34
|
end
|
|
34
35
|
end
|
|
@@ -93,18 +94,22 @@ module Suma
|
|
|
93
94
|
|
|
94
95
|
return unless parts.size > 2
|
|
95
96
|
|
|
96
|
-
error = validate_deep_path(schema, element, parts[2..], file, line_idx,
|
|
97
|
+
error = validate_deep_path(schema, element, parts[2..], file, line_idx,
|
|
98
|
+
link)
|
|
97
99
|
unresolved << error if error
|
|
98
100
|
end
|
|
99
101
|
|
|
100
|
-
def validate_deep_path(schema, element, path_parts, file, line_idx,
|
|
102
|
+
def validate_deep_path(schema, element, path_parts, file, line_idx,
|
|
103
|
+
full_link)
|
|
101
104
|
current = element
|
|
102
105
|
current_path = "#{schema.id}.#{element.id}"
|
|
103
106
|
|
|
104
107
|
path_parts.each do |part|
|
|
105
108
|
case current
|
|
106
109
|
when Expressir::Model::Declarations::Entity
|
|
107
|
-
attribute = current.attributes&.find
|
|
110
|
+
attribute = current.attributes&.find do |a|
|
|
111
|
+
a.id.downcase == part.downcase
|
|
112
|
+
end
|
|
108
113
|
|
|
109
114
|
unless attribute
|
|
110
115
|
return LinkValidationResult.new(
|
|
@@ -122,7 +127,9 @@ module Suma
|
|
|
122
127
|
underlying = current.underlying_type
|
|
123
128
|
|
|
124
129
|
if underlying.is_a?(Expressir::Model::DataTypes::Enumeration)
|
|
125
|
-
enum_value = underlying.items.find
|
|
130
|
+
enum_value = underlying.items.find do |e|
|
|
131
|
+
e.id.downcase == part.downcase
|
|
132
|
+
end
|
|
126
133
|
|
|
127
134
|
unless enum_value
|
|
128
135
|
return LinkValidationResult.new(
|
|
@@ -193,7 +200,9 @@ module Suma
|
|
|
193
200
|
schema.procedures,
|
|
194
201
|
schema.subtype_constraints,
|
|
195
202
|
].each do |collection|
|
|
196
|
-
element = collection&.find
|
|
203
|
+
element = collection&.find do |e|
|
|
204
|
+
e.id.downcase == element_name.downcase
|
|
205
|
+
end
|
|
197
206
|
return element if element
|
|
198
207
|
end
|
|
199
208
|
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "expressir"
|
|
4
|
+
|
|
5
|
+
module Suma
|
|
6
|
+
# Tree-walking service over a CollectionManifest.
|
|
7
|
+
#
|
|
8
|
+
# ManifestTraverser owns all imperative operations that walk or mutate a
|
|
9
|
+
# manifest tree: finding schemas-only entries, expanding them into
|
|
10
|
+
# compiled-doc sub-trees, exporting a unified Expressir::SchemaManifest,
|
|
11
|
+
# and removing schemas-only sources after compilation.
|
|
12
|
+
#
|
|
13
|
+
# Each method takes the manifest passed at construction as its root;
|
|
14
|
+
# recursion instantiates a new traverser per child node so the surface
|
|
15
|
+
# stays instance-based and the data model (CollectionManifest) stays pure.
|
|
16
|
+
#
|
|
17
|
+
# Schema-config loading and doc-entry construction are delegated to
|
|
18
|
+
# SchemaDiscovery so this class owns traversal, not schema I/O.
|
|
19
|
+
class ManifestTraverser
|
|
20
|
+
attr_reader :manifest
|
|
21
|
+
|
|
22
|
+
def initialize(manifest)
|
|
23
|
+
@manifest = manifest
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Returns every entry (anywhere in the tree) whose `schemas_only` flag
|
|
27
|
+
# is truthy, plus +manifest+ itself if it is schemas-only.
|
|
28
|
+
def find_schemas_only
|
|
29
|
+
results = (manifest.entry || []).select(&:schemas_only)
|
|
30
|
+
results << manifest if manifest.schemas_only
|
|
31
|
+
results
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Recursively concatenate every nested schema_config into a single
|
|
35
|
+
# Expressir::SchemaManifest. The manifest's own schema_config (if set)
|
|
36
|
+
# seeds the result; otherwise an empty SchemaManifest is returned.
|
|
37
|
+
def export_schema_config(path)
|
|
38
|
+
export_config = manifest.schema_config || Expressir::SchemaManifest.new
|
|
39
|
+
return export_config unless manifest.entry
|
|
40
|
+
|
|
41
|
+
manifest.entry.each do |child|
|
|
42
|
+
child_config = self.class.new(child).export_schema_config(path)
|
|
43
|
+
export_config.concat(child_config) if child_config
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
export_config
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Expand schemas-only entries into compiled-doc sub-trees. If the
|
|
50
|
+
# manifest has no file, walks children via process_entry. Otherwise
|
|
51
|
+
# loads the schema_config (SchemaDiscovery), and if this entry is
|
|
52
|
+
# schemas-only, hides it from the output index and appends a new
|
|
53
|
+
# sub-collection that hosts the compiled docs.
|
|
54
|
+
def expand_schemas_only(schema_output_path)
|
|
55
|
+
return process_entry(schema_output_path) unless manifest.file
|
|
56
|
+
|
|
57
|
+
SchemaDiscovery.new(manifest).load_config
|
|
58
|
+
|
|
59
|
+
return process_entry(schema_output_path) unless manifest.schemas_only
|
|
60
|
+
|
|
61
|
+
manifest.index = false
|
|
62
|
+
|
|
63
|
+
added = SchemaDiscovery.new(manifest).build_added_manifest(schema_output_path)
|
|
64
|
+
[manifest, added]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Drop every direct child whose `schemas_only` is truthy. Called after
|
|
68
|
+
# compilation so the schemas-only manifest file no longer shows up in
|
|
69
|
+
# the rendered collection.
|
|
70
|
+
def remove_schemas_only_sources
|
|
71
|
+
return unless manifest.entry
|
|
72
|
+
|
|
73
|
+
kept = manifest.entry.reject(&:schemas_only)
|
|
74
|
+
manifest.entry = kept
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
# Walk each child via expand_schemas_only, flattening the results back
|
|
80
|
+
# into manifest.entry.
|
|
81
|
+
def process_entry(schema_output_path)
|
|
82
|
+
return [manifest] unless manifest.entry
|
|
83
|
+
|
|
84
|
+
flattened = manifest.entry.each_with_object([]) do |child, acc|
|
|
85
|
+
acc.concat(self.class.new(child).expand_schemas_only(schema_output_path))
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
manifest.entry = flattened
|
|
89
|
+
[manifest]
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
data/lib/suma/processor.rb
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "schema_collection"
|
|
4
|
-
require_relative "utils"
|
|
5
|
-
require_relative "collection_config"
|
|
6
|
-
require_relative "site_config"
|
|
7
3
|
require "metanorma"
|
|
8
4
|
|
|
9
5
|
module Suma
|
|
@@ -41,11 +37,12 @@ module Suma
|
|
|
41
37
|
collection_config_path = site_config.metanorma.source.files.first
|
|
42
38
|
collection_config = Suma::CollectionConfig.from_file(collection_config_path)
|
|
43
39
|
collection_config.path = collection_config_path
|
|
44
|
-
collection_config.manifest.expand_schemas_only("schema_docs")
|
|
45
40
|
|
|
46
|
-
|
|
47
|
-
|
|
41
|
+
traverser = ManifestTraverser.new(collection_config.manifest)
|
|
42
|
+
traverser.expand_schemas_only("schema_docs")
|
|
48
43
|
|
|
44
|
+
exported_schema_config = traverser.export_schema_config(schemas_all_path)
|
|
45
|
+
exported_schema_config.path = schemas_all_path
|
|
49
46
|
exported_schema_config.to_file
|
|
50
47
|
|
|
51
48
|
collection_config
|
|
@@ -72,7 +69,7 @@ module Suma
|
|
|
72
69
|
|
|
73
70
|
def build_collection(collection_config, output_directory)
|
|
74
71
|
new_collection_config_path = "collection-output.yaml"
|
|
75
|
-
collection_config.manifest.remove_schemas_only_sources
|
|
72
|
+
ManifestTraverser.new(collection_config.manifest).remove_schemas_only_sources
|
|
76
73
|
collection_config.to_file(new_collection_config_path)
|
|
77
74
|
|
|
78
75
|
collection = Metanorma::Collection.parse(new_collection_config_path)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "pathname"
|
|
5
|
+
require "expressir"
|
|
6
|
+
require "glossarist"
|
|
7
|
+
|
|
8
|
+
module Suma
|
|
9
|
+
# Generates a Glossarist v3 register.yaml from an EXPRESS schema manifest.
|
|
10
|
+
#
|
|
11
|
+
# The schema manifest (schemas-smrl-part-2.yml) is the single source of
|
|
12
|
+
# truth for schema identities and paths. This class reads it, classifies
|
|
13
|
+
# each schema by type (resource/module), and emits a register.yaml with
|
|
14
|
+
# hierarchical sections and human-readable names.
|
|
15
|
+
#
|
|
16
|
+
# Architecture:
|
|
17
|
+
# - Classification: delegates to ExpressSchema::Type (DRY)
|
|
18
|
+
# - Naming: delegates to SchemaNaming (OCP — extend naming without
|
|
19
|
+
# touching this class)
|
|
20
|
+
# - Sections: built from Glossarist::Section models (model-driven)
|
|
21
|
+
# - URN semantics: delegates to Suma::Urn (OCP)
|
|
22
|
+
#
|
|
23
|
+
# @example
|
|
24
|
+
# generator = RegisterManifestGenerator.new(
|
|
25
|
+
# "schemas-smrl-part-2.yml",
|
|
26
|
+
# urn: "urn:iso:std:iso:10303:-2:ed-2:en:tech:*",
|
|
27
|
+
# id: "iso10303-2-express",
|
|
28
|
+
# ref: "ISO 10303-2 EXPRESS Concepts",
|
|
29
|
+
# )
|
|
30
|
+
# generator.generate # writes register.yaml
|
|
31
|
+
class RegisterManifestGenerator
|
|
32
|
+
DEFAULT_OWNER = "ISO/TC 184/SC 4"
|
|
33
|
+
DEFAULT_STATUS = "current"
|
|
34
|
+
DEFAULT_ORDERING = "systematic"
|
|
35
|
+
DEFAULT_SCHEMA_TYPE = "glossarist"
|
|
36
|
+
DEFAULT_SCHEMA_VERSION = "3"
|
|
37
|
+
|
|
38
|
+
# @param schema_manifest_file [String] path to schemas-smrl-part-2.yml
|
|
39
|
+
# @param output_path [String] directory to write register.yaml
|
|
40
|
+
# @param urn [String] base URN for the dataset
|
|
41
|
+
# @param id [String] dataset identifier
|
|
42
|
+
# @param ref [String] human-readable reference label
|
|
43
|
+
# @param language_code [String] language for section names
|
|
44
|
+
# @param owner [String] dataset owner organisation
|
|
45
|
+
def initialize(schema_manifest_file, output_path, urn:, id:, ref:,
|
|
46
|
+
language_code: "eng", owner: DEFAULT_OWNER)
|
|
47
|
+
@schema_manifest_file = File.expand_path(schema_manifest_file)
|
|
48
|
+
@output_path = output_path
|
|
49
|
+
@urn = Suma::Urn.new(urn)
|
|
50
|
+
@id = id
|
|
51
|
+
@ref = ref
|
|
52
|
+
@language_code = language_code
|
|
53
|
+
@owner = owner
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Generate and write register.yaml.
|
|
57
|
+
#
|
|
58
|
+
# @return [Glossarist::DatasetRegister] the generated register
|
|
59
|
+
def generate
|
|
60
|
+
validate_inputs
|
|
61
|
+
schemas = load_schemas
|
|
62
|
+
register = build_register(schemas)
|
|
63
|
+
|
|
64
|
+
FileUtils.mkdir_p(@output_path)
|
|
65
|
+
output_file = File.join(@output_path, "register.yaml")
|
|
66
|
+
File.write(output_file, register.to_yaml)
|
|
67
|
+
Utils.log "Generated register.yaml: #{output_file}"
|
|
68
|
+
Utils.log " #{schemas.length} schemas in #{register.sections.length} categories"
|
|
69
|
+
|
|
70
|
+
register
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Verify that the manifest path exists, is a regular file, and contains
|
|
76
|
+
# at least one schema entry. Called from {#generate} so both the CLI
|
|
77
|
+
# shell and direct construction get the same validation behavior.
|
|
78
|
+
#
|
|
79
|
+
# @raise [Errno::ENOENT] when the manifest path is missing or not a file
|
|
80
|
+
# @raise [ArgumentError] when the manifest is empty
|
|
81
|
+
def validate_inputs
|
|
82
|
+
unless File.exist?(@schema_manifest_file)
|
|
83
|
+
raise Errno::ENOENT, "Specified SCHEMA_MANIFEST_FILE " \
|
|
84
|
+
"`#{@schema_manifest_file}` not found."
|
|
85
|
+
end
|
|
86
|
+
unless File.file?(@schema_manifest_file)
|
|
87
|
+
raise Errno::ENOENT, "Specified SCHEMA_MANIFEST_FILE " \
|
|
88
|
+
"`#{@schema_manifest_file}` is not a file."
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Load all schemas from the manifest file as SchemaManifestEntry models.
|
|
93
|
+
#
|
|
94
|
+
# @return [Array<Expressir::SchemaManifestEntry>]
|
|
95
|
+
def load_schemas
|
|
96
|
+
manifest = Expressir::SchemaManifest.from_file(@schema_manifest_file)
|
|
97
|
+
schemas = manifest.schemas.sort_by { |s| s.id.downcase }
|
|
98
|
+
if schemas.empty?
|
|
99
|
+
raise ArgumentError, "No schemas found in manifest " \
|
|
100
|
+
"`#{@schema_manifest_file}`."
|
|
101
|
+
end
|
|
102
|
+
schemas
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Build a fully-populated DatasetRegister model.
|
|
106
|
+
#
|
|
107
|
+
# @param schemas [Array<Expressir::SchemaManifestEntry>]
|
|
108
|
+
# @return [Glossarist::DatasetRegister]
|
|
109
|
+
def build_register(schemas)
|
|
110
|
+
Glossarist::DatasetRegister.new(
|
|
111
|
+
schema_type: DEFAULT_SCHEMA_TYPE,
|
|
112
|
+
schema_version: DEFAULT_SCHEMA_VERSION,
|
|
113
|
+
id: @id,
|
|
114
|
+
ref: @ref,
|
|
115
|
+
urn: @urn.to_s,
|
|
116
|
+
urn_aliases: @urn.aliases,
|
|
117
|
+
status: DEFAULT_STATUS,
|
|
118
|
+
owner: @owner,
|
|
119
|
+
languages: [@language_code],
|
|
120
|
+
ordering: DEFAULT_ORDERING,
|
|
121
|
+
sections: build_sections(schemas),
|
|
122
|
+
)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# Build a list of top-level sections, one per category, in
|
|
126
|
+
# SchemaCategory::ALL declaration order. Categories with no
|
|
127
|
+
# schemas are omitted.
|
|
128
|
+
#
|
|
129
|
+
# @param schemas [Array<Expressir::SchemaManifestEntry>]
|
|
130
|
+
# @return [Array<Glossarist::Section>]
|
|
131
|
+
def build_sections(schemas)
|
|
132
|
+
groups = schemas.group_by do |s|
|
|
133
|
+
SchemaCategory.for_schema(id: s.id, path: s.path)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
SchemaCategory::ALL.filter_map do |category|
|
|
137
|
+
children = groups[category] || []
|
|
138
|
+
next if children.empty?
|
|
139
|
+
|
|
140
|
+
Glossarist::Section.new(
|
|
141
|
+
id: category.id,
|
|
142
|
+
names: { @language_code => category.label },
|
|
143
|
+
children: children.map { |s| build_section(s) },
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Build a single leaf section from a schema descriptor.
|
|
149
|
+
#
|
|
150
|
+
# @param schema [Expressir::SchemaManifestEntry]
|
|
151
|
+
# @return [Glossarist::Section]
|
|
152
|
+
def build_section(schema)
|
|
153
|
+
Glossarist::Section.new(
|
|
154
|
+
id: schema.id,
|
|
155
|
+
names: {
|
|
156
|
+
@language_code => SchemaNaming.prefixed_name(
|
|
157
|
+
schema.id, path: schema.path
|
|
158
|
+
),
|
|
159
|
+
},
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|