suma 0.3.0 → 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 +2 -5
- data/README.adoc +70 -12
- data/lib/suma/cli/check_svg_quality.rb +82 -91
- data/lib/suma/cli/export.rb +37 -19
- data/lib/suma/cli/reformat.rb +39 -63
- data/lib/suma/cli/validate.rb +14 -5
- data/lib/suma/cli/validate_links.rb +11 -155
- data/lib/suma/express_reformatter.rb +94 -0
- data/lib/suma/link_validation.rb +144 -0
- data/lib/suma/link_validator.rb +2 -1
- data/lib/suma/schema_collection.rb +1 -1
- data/lib/suma/schema_exporter.rb +14 -26
- data/lib/suma/svg_quality/scanner.rb +61 -0
- data/lib/suma/svg_quality.rb +1 -0
- data/lib/suma/term_classification.rb +78 -0
- data/lib/suma/term_extractor.rb +16 -45
- data/lib/suma/version.rb +1 -1
- data/lib/suma.rb +4 -0
- metadata +6 -2
|
@@ -1,168 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "thor"
|
|
4
|
-
require "expressir"
|
|
5
4
|
|
|
6
5
|
module Suma
|
|
7
6
|
module Cli
|
|
7
|
+
# Deprecated: prefer +Cli::Validate#links+ (the +suma validate links+
|
|
8
|
+
# subcommand). This class is retained as a backwards-compat entry
|
|
9
|
+
# point — all orchestration now lives in +Suma::LinkValidation+.
|
|
8
10
|
class ValidateLinks < Thor
|
|
9
11
|
desc "extract_and_validate SCHEMAS_FILE DOCUMENTS_PATH [OUTPUT_FILE]",
|
|
10
12
|
"Extract and validate express links without creating intermediate file"
|
|
11
13
|
def extract_and_validate(schemas_file = "schemas-srl.yml",
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
all_files = adoc_files + exp_files
|
|
22
|
-
display_file_counts(adoc_files, exp_files)
|
|
23
|
-
|
|
24
|
-
links_by_file = extract_links(all_files)
|
|
25
|
-
|
|
26
|
-
repo = load_express_schemas(schemas_config)
|
|
27
|
-
index = SchemaIndex.new(repo)
|
|
28
|
-
unresolved = LinkValidator.new(index).validate(links_by_file)
|
|
29
|
-
|
|
30
|
-
write_validation_results(paths[:output_file], paths[:output_file_rel],
|
|
31
|
-
unresolved, links_by_file)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
def load_dependencies
|
|
37
|
-
require "expressir"
|
|
38
|
-
require "ruby-progressbar"
|
|
39
|
-
require "pathname"
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def prepare_file_paths(schemas_file, documents_path, output_file)
|
|
43
|
-
schemas_file_path = Pathname.new(schemas_file).expand_path
|
|
44
|
-
documents_path_exp = Pathname.new(documents_path).expand_path
|
|
45
|
-
output_file_path = Pathname.new(output_file).expand_path
|
|
46
|
-
|
|
47
|
-
schemas_file_rel = Pathname.new(schemas_file_path).relative_path_from(Pathname.pwd).to_s
|
|
48
|
-
documents_path_rel = Pathname.new(documents_path_exp).relative_path_from(Pathname.pwd).to_s
|
|
49
|
-
output_file_rel = Pathname.new(output_file_path).relative_path_from(Pathname.pwd).to_s
|
|
50
|
-
|
|
51
|
-
puts "Extracting and validating express links using schemas from #{schemas_file_rel}..."
|
|
52
|
-
puts "Looking for documents in #{documents_path_rel}..."
|
|
53
|
-
|
|
54
|
-
{
|
|
55
|
-
schemas_file: schemas_file_path,
|
|
56
|
-
schemas_file_rel: schemas_file_rel,
|
|
57
|
-
documents_path: documents_path_exp,
|
|
58
|
-
documents_path_rel: documents_path_rel,
|
|
59
|
-
output_file: output_file_path,
|
|
60
|
-
output_file_rel: output_file_rel,
|
|
61
|
-
}
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def load_schemas_config(schemas_file_path)
|
|
65
|
-
schemas_config = Expressir::SchemaManifest.from_yaml(File.read(schemas_file_path))
|
|
66
|
-
schemas_config.set_initial_path(schemas_file_path.to_s)
|
|
67
|
-
schemas_config
|
|
68
|
-
rescue StandardError => e
|
|
69
|
-
raise Suma::Error, "Error loading schemas file: #{e.message}"
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
def collect_schema_paths(schemas_config, schemas_file_rel)
|
|
73
|
-
exp_files = schemas_config.schemas.filter_map(&:path)
|
|
74
|
-
puts "Found #{exp_files.size} EXPRESS schema files from #{schemas_file_rel}"
|
|
75
|
-
exp_files
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def find_adoc_files(documents_path)
|
|
79
|
-
Dir.glob(documents_path.join("**", "*.adoc").to_s)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def display_file_counts(adoc_files, exp_files)
|
|
83
|
-
puts "Found #{adoc_files.size} AsciiDoc files and #{exp_files.size} EXPRESS files"
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def create_progress_bar(title, total)
|
|
87
|
-
ProgressBar.create(
|
|
88
|
-
title: title,
|
|
89
|
-
total: total,
|
|
90
|
-
format: "%t: [%B] %p%% %c/%C %e",
|
|
91
|
-
progress_mark: "=",
|
|
92
|
-
remainder_mark: " ",
|
|
93
|
-
length: 80,
|
|
94
|
-
)
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def extract_links(files)
|
|
98
|
-
links_by_file = {}
|
|
99
|
-
link_count = 0
|
|
100
|
-
|
|
101
|
-
progress = create_progress_bar("Processing files", files.size)
|
|
102
|
-
|
|
103
|
-
files.each do |file|
|
|
104
|
-
progress.increment
|
|
105
|
-
begin
|
|
106
|
-
content = File.read(file)
|
|
107
|
-
express_links = content.scan(/<<express:([^,>]+)(?:,[^>]+)?>>/).flatten.uniq
|
|
108
|
-
|
|
109
|
-
if express_links.any?
|
|
110
|
-
links_by_file[file] = express_links
|
|
111
|
-
link_count += express_links.size
|
|
112
|
-
end
|
|
113
|
-
rescue StandardError => e
|
|
114
|
-
puts "\nWarning: Could not read file #{file}: #{e.message}"
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
puts "\nExtracted #{link_count} unique express links from #{links_by_file.size} files"
|
|
119
|
-
links_by_file
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def load_express_schemas(schemas_config)
|
|
123
|
-
schema_paths = {}
|
|
124
|
-
schemas_config.schemas.each { |s| schema_paths[s.id] = s.path }
|
|
125
|
-
|
|
126
|
-
puts "Loading #{schema_paths.size} EXPRESS schemas for validation..."
|
|
127
|
-
|
|
128
|
-
loading_progress = create_progress_bar("Loading schemas", schema_paths.size)
|
|
129
|
-
|
|
130
|
-
begin
|
|
131
|
-
repo = Expressir::Express::Parser.from_files(schema_paths.values) do |filename, _schemas, error|
|
|
132
|
-
loading_progress.increment
|
|
133
|
-
puts "\nWarning: Error loading schema #{filename}: #{error.message}" if error
|
|
134
|
-
end
|
|
135
|
-
|
|
136
|
-
puts "Successfully loaded #{repo.schemas.size} schemas"
|
|
137
|
-
repo
|
|
138
|
-
rescue StandardError => e
|
|
139
|
-
raise Suma::Error, "Error loading schemas: #{e.message}"
|
|
140
|
-
end
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def write_validation_results(output_file_path, output_file_rel,
|
|
144
|
-
unresolved_links, links_by_file)
|
|
145
|
-
total_links = links_by_file.values.sum(&:size)
|
|
146
|
-
|
|
147
|
-
results = []
|
|
148
|
-
results << "Validation complete. Checked #{total_links} links."
|
|
149
|
-
|
|
150
|
-
if unresolved_links.empty?
|
|
151
|
-
results << "✅ All links resolved successfully!"
|
|
152
|
-
else
|
|
153
|
-
results << "❌ Found #{unresolved_links.size} unresolved links:"
|
|
154
|
-
unresolved_links.each do |issue|
|
|
155
|
-
results << "#{issue.file}:#{issue.line} - <<express:#{issue.link}>> - #{issue.reason}"
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
begin
|
|
160
|
-
File.write(output_file_path, results.join("\n"))
|
|
161
|
-
puts "Validation results written to #{output_file_rel}"
|
|
162
|
-
rescue StandardError => e
|
|
163
|
-
puts "Error writing to output file: #{e.message}"
|
|
164
|
-
puts results
|
|
165
|
-
end
|
|
14
|
+
documents_path = "documents",
|
|
15
|
+
output_file = "validation_results.txt")
|
|
16
|
+
result = LinkValidation.new(
|
|
17
|
+
schemas_file: schemas_file,
|
|
18
|
+
documents_path: documents_path,
|
|
19
|
+
output_file: output_file,
|
|
20
|
+
).call
|
|
21
|
+
puts LinkValidation.generate_summary(result)
|
|
166
22
|
end
|
|
167
23
|
end
|
|
168
24
|
end
|
|
@@ -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
|
|
@@ -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,6 +1,7 @@
|
|
|
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,
|
|
@@ -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
|
data/lib/suma/schema_exporter.rb
CHANGED
|
@@ -3,8 +3,19 @@
|
|
|
3
3
|
require "fileutils"
|
|
4
4
|
|
|
5
5
|
module Suma
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
|
|
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
|
data/lib/suma/svg_quality.rb
CHANGED
|
@@ -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
|