suma 0.3.0 → 0.5.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/core.rb +32 -4
- 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/eengine_converter.rb +9 -8
- data/lib/suma/express_reformatter.rb +94 -0
- data/lib/suma/link_validation.rb +144 -0
- data/lib/suma/link_validator/step.rb +149 -0
- data/lib/suma/link_validator.rb +22 -98
- data/lib/suma/note_processor.rb +270 -0
- data/lib/suma/schema_collection.rb +1 -1
- data/lib/suma/schema_comparer.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 +25 -240
- data/lib/suma/version.rb +1 -1
- data/lib/suma.rb +5 -0
- metadata +8 -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
|
|
@@ -3,24 +3,25 @@
|
|
|
3
3
|
require "expressir/commands/changes_import_eengine"
|
|
4
4
|
|
|
5
5
|
module Suma
|
|
6
|
-
# Converts eengine comparison XML to Expressir::Changes::SchemaChange
|
|
7
|
-
#
|
|
6
|
+
# Converts eengine comparison XML to Expressir::Changes::SchemaChange.
|
|
7
|
+
#
|
|
8
|
+
# Pure converter: takes XML content as a string (no I/O). The caller
|
|
9
|
+
# is responsible for reading the file. This keeps the object cheap
|
|
10
|
+
# to construct and easy to test in isolation — no fixture file
|
|
11
|
+
# required just to instantiate.
|
|
8
12
|
class EengineConverter
|
|
9
|
-
def initialize(
|
|
10
|
-
@xml_path = xml_path
|
|
13
|
+
def initialize(schema_name, xml_content)
|
|
11
14
|
@schema_name = schema_name
|
|
12
|
-
@xml_content =
|
|
15
|
+
@xml_content = xml_content
|
|
13
16
|
end
|
|
14
17
|
|
|
15
|
-
# Convert the eengine XML to a ChangeSchema
|
|
18
|
+
# Convert the eengine XML to a ChangeSchema.
|
|
16
19
|
#
|
|
17
20
|
# @param version [String] Version number for this change version
|
|
18
21
|
# @param existing_change_schema [Expressir::Changes::SchemaChange, nil]
|
|
19
22
|
# Existing schema to append to, or nil to create new
|
|
20
23
|
# @return [Expressir::Changes::SchemaChange] The updated change schema
|
|
21
24
|
def convert(version:, existing_change_schema: nil)
|
|
22
|
-
# Use Expressir's built-in conversion which properly handles
|
|
23
|
-
# HTML elements in descriptions
|
|
24
25
|
Expressir::Commands::ChangesImportEengine.from_xml(
|
|
25
26
|
@xml_content,
|
|
26
27
|
@schema_name,
|
|
@@ -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
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "expressir"
|
|
4
|
+
|
|
5
|
+
module Suma
|
|
6
|
+
class LinkValidator
|
|
7
|
+
# Per-node-type navigation strategies for +validate_deep_path+.
|
|
8
|
+
#
|
|
9
|
+
# Each Step handles one EXPRESS construct (entity, type, ...) and
|
|
10
|
+
# knows how to walk one path segment from it. The dispatcher in
|
|
11
|
+
# LinkValidator picks the right Step by +handles?+.
|
|
12
|
+
#
|
|
13
|
+
# Adding a new navigable construct means adding a new Step class
|
|
14
|
+
# and registering it in REGISTRY — no edits to existing Steps or
|
|
15
|
+
# to validate_deep_path (open/closed principle).
|
|
16
|
+
module Step
|
|
17
|
+
Context = Struct.new(:file, :line, :link, :schema, :path,
|
|
18
|
+
keyword_init: true)
|
|
19
|
+
|
|
20
|
+
# Common failure-result builder. Step subclasses call this to
|
|
21
|
+
# surface an unresolved link.
|
|
22
|
+
module Failure
|
|
23
|
+
def failure(reason, context)
|
|
24
|
+
LinkValidationResult.new(
|
|
25
|
+
file: context.file,
|
|
26
|
+
line: context.line + 1,
|
|
27
|
+
link: context.link,
|
|
28
|
+
reason: reason,
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Navigates one path segment from an Entity by attribute name.
|
|
34
|
+
class Entity
|
|
35
|
+
include Failure
|
|
36
|
+
|
|
37
|
+
def self.handles?(node)
|
|
38
|
+
node.is_a?(Expressir::Model::Declarations::Entity)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def navigate(node, part, context)
|
|
42
|
+
attribute = node.attributes&.find { |a| a.id.downcase == part.downcase }
|
|
43
|
+
unless attribute
|
|
44
|
+
return failure("Attribute '#{part}' not found in entity '#{context.path}'",
|
|
45
|
+
context)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
attribute
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Navigates one path segment from a Type by either enumeration
|
|
53
|
+
# value (for enum types) or by re-targeting to the underlying
|
|
54
|
+
# named type (for non-enum types).
|
|
55
|
+
class Type
|
|
56
|
+
include Failure
|
|
57
|
+
|
|
58
|
+
PRIMITIVE_TYPE_NAMES = %w[INTEGER REAL STRING BOOLEAN NUMBER BINARY
|
|
59
|
+
LOGICAL].freeze
|
|
60
|
+
|
|
61
|
+
def self.handles?(node)
|
|
62
|
+
node.is_a?(Expressir::Model::Declarations::Type)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def navigate(node, part, context)
|
|
66
|
+
underlying = node.underlying_type
|
|
67
|
+
unless underlying
|
|
68
|
+
return failure("Cannot navigate deeper from type '#{context.path}'",
|
|
69
|
+
context)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
if underlying.is_a?(Expressir::Model::DataTypes::Enumeration)
|
|
73
|
+
navigate_enum(underlying, part, context)
|
|
74
|
+
else
|
|
75
|
+
navigate_underlying(underlying, context)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
private
|
|
80
|
+
|
|
81
|
+
def navigate_enum(underlying, part, context)
|
|
82
|
+
enum_value = underlying.items.find { |e| e.id.downcase == part.downcase }
|
|
83
|
+
unless enum_value
|
|
84
|
+
return failure("Enumeration value '#{part}' not found in type '#{context.path}'",
|
|
85
|
+
context)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
enum_value
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def navigate_underlying(underlying, context)
|
|
92
|
+
base_type = resolve_base_type(context.schema, underlying)
|
|
93
|
+
unless base_type
|
|
94
|
+
return failure("Base type not found for '#{context.path}'",
|
|
95
|
+
context)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
base_type
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def resolve_base_type(schema, type_ref)
|
|
102
|
+
return nil if PRIMITIVE_TYPE_NAMES.include?(type_ref.to_s.upcase)
|
|
103
|
+
return find_schema_element(schema, type_ref) if type_ref.is_a?(String)
|
|
104
|
+
|
|
105
|
+
type_ref if type_ref.is_a?(Expressir::Model::ModelElement)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def find_schema_element(schema, name)
|
|
109
|
+
SchemaElementLookup.find(schema, name)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Lookup helper shared across Step classes that need to resolve
|
|
114
|
+
# a named schema element (used by Type for underlying-type
|
|
115
|
+
# resolution). Lives behind its own module so Steps don't reach
|
|
116
|
+
# into LinkValidator's private methods.
|
|
117
|
+
module SchemaElementLookup
|
|
118
|
+
def self.find(schema, name)
|
|
119
|
+
collections(schema).each do |collection|
|
|
120
|
+
element = collection&.find { |e| e.id.downcase == name.downcase }
|
|
121
|
+
return element if element
|
|
122
|
+
end
|
|
123
|
+
nil
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def self.collections(schema)
|
|
127
|
+
[
|
|
128
|
+
schema.entities,
|
|
129
|
+
schema.types,
|
|
130
|
+
schema.constants,
|
|
131
|
+
schema.functions,
|
|
132
|
+
schema.rules,
|
|
133
|
+
schema.procedures,
|
|
134
|
+
schema.subtype_constraints,
|
|
135
|
+
]
|
|
136
|
+
end
|
|
137
|
+
private_class_method :collections
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
REGISTRY = [Entity, Type].freeze
|
|
141
|
+
|
|
142
|
+
# Returns the Step class that handles +node+, or +nil+ if no
|
|
143
|
+
# registered Step matches.
|
|
144
|
+
def self.for(node)
|
|
145
|
+
REGISTRY.find { |step| step.handles?(node) }
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|