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.
@@ -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
- documents_path = "documents",
13
- output_file = "validation_results.txt")
14
- load_dependencies
15
- paths = prepare_file_paths(schemas_file, documents_path, output_file)
16
-
17
- schemas_config = load_schemas_config(paths[:schemas_file])
18
- exp_files = collect_schema_paths(schemas_config, paths[:schemas_file_rel])
19
- adoc_files = find_adoc_files(paths[:documents_path])
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
- # This is a thin wrapper around Expressir's ChangesImportEengine command
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(xml_path, schema_name)
10
- @xml_path = xml_path
13
+ def initialize(schema_name, xml_content)
11
14
  @schema_name = schema_name
12
- @xml_content = File.read(xml_path)
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