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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 041f227f2bd35c3d90138ab621e51b9abf68206f7b295523da2827d82d6b63c7
4
- data.tar.gz: f64aed0f1f4aa096d66c4c299581a2437a991fe6f522b50170b26557b81a0092
3
+ metadata.gz: 304c4c7a2ec03c83604a127370259b022e0102d303c4951c2d730d3681d3ad39
4
+ data.tar.gz: b8d9bcd807ea0aadb4f038dd58823d829d495827e4785316cb7c715e79b512cb
5
5
  SHA512:
6
- metadata.gz: ac0d654f283a393c6ae620763920decec16ce2bad44c5cc5722f123bf12ee7539069e35dbbb4909f061fdb14e240f26f033d04e357cfdaff814966cc5f185f8b
7
- data.tar.gz: a2a4ca844993d87bc534c77d99e5767aa0f6f86b1f028a15c9a9387ddc761303fcef390a3fe98dce907580b8da1657bb83f96d7c4d0cdd0e03f59d690a1c688f
6
+ metadata.gz: 21977b339cd0a16936940c50de766ba2130f3dc46df7965db1de04360a65c1d685e1b59786e9a49548d0e79f1d20befcf732a66482b6e8b8793a547bd557d28e
7
+ data.tar.gz: bd1122e6d73a9a22b3a3830c7cf654c909a541e7c332808349b15bb469f772338ad7a84a25590f1c8d13b46b84d8399673fa2f43f16f5d943e0e1f8e2469a08a
data/.gitignore CHANGED
@@ -13,11 +13,8 @@
13
13
  .ruby-version
14
14
  Gemfile.lock
15
15
 
16
- # local planning scratch directories (not for commit)
17
- /TODO.clean/
18
- /TODO.cleanup/
19
- /TODO.refactor/
20
- /TODO.remaining/
16
+ # local planning scratch directories and files (not for commit)
17
+ /TODO*
21
18
 
22
19
  # vendored gems (use Gemfile / Gemfile.lock elsewhere)
23
20
  /*.gem
data/README.adoc CHANGED
@@ -1065,34 +1065,31 @@ examples demonstrate common usage patterns.
1065
1065
  require 'suma'
1066
1066
 
1067
1067
  # Build a collection with default settings
1068
- Suma::Processor.run(
1068
+ Suma::Processor.new(
1069
1069
  metanorma_yaml_path: "metanorma-srl.yml",
1070
1070
  schemas_all_path: "schemas-srl.yml",
1071
1071
  compile: true,
1072
1072
  output_directory: "_site"
1073
- )
1073
+ ).run
1074
1074
 
1075
1075
  # Generate schema listing without compilation
1076
- Suma::Processor.run(
1076
+ Suma::Processor.new(
1077
1077
  metanorma_yaml_path: "metanorma-srl.yml",
1078
1078
  schemas_all_path: "schemas-srl.yml",
1079
- compile: false,
1080
- output_directory: "_site"
1081
- )
1079
+ compile: false
1080
+ ).run
1082
1081
  ----
1083
1082
 
1084
- === Working with schema configurations
1083
+ === Working with schema manifests
1084
+
1085
+ Schema manifests are loaded via `Expressir::SchemaManifest`:
1085
1086
 
1086
1087
  [source,ruby]
1087
1088
  ----
1088
1089
  require 'suma'
1089
1090
 
1090
- # Load schemas using SchemaConfig
1091
1091
  schemas_file_path = "schemas-srl.yml"
1092
- schemas_config = Suma::SchemaConfig::Config.from_yaml(IO.read(schemas_file_path))
1093
-
1094
- # Set the initial path to resolve relative paths
1095
- schemas_config.set_initial_path(schemas_file_path)
1092
+ schemas_config = Expressir::SchemaManifest.from_file(schemas_file_path)
1096
1093
 
1097
1094
  # Access schema information
1098
1095
  schemas_config.schemas.each do |schema|
@@ -1101,6 +1098,67 @@ schemas_config.schemas.each do |schema|
1101
1098
  end
1102
1099
  ----
1103
1100
 
1101
+ === Extracting terms
1102
+
1103
+ [source,ruby]
1104
+ ----
1105
+ require 'suma'
1106
+
1107
+ Suma::TermExtractor.new(
1108
+ "schemas-smrl-part-2.yml",
1109
+ "glossarist_output",
1110
+ urn: "urn:iso:std:iso:10303:-2:ed-2:en:tech:*",
1111
+ ).call
1112
+ ----
1113
+
1114
+ === Generating a register.yaml
1115
+
1116
+ [source,ruby]
1117
+ ----
1118
+ require 'suma'
1119
+
1120
+ Suma::RegisterManifestGenerator.new(
1121
+ "schemas-smrl-part-2.yml",
1122
+ "register_output",
1123
+ urn: "urn:iso:std:iso:10303:-2:ed-2:en:tech:*",
1124
+ id: "iso10303-2-express",
1125
+ ref: "ISO 10303-2 EXPRESS Concepts",
1126
+ ).generate
1127
+ ----
1128
+
1129
+ === Architecture
1130
+
1131
+ Suma's domain is split into MECE concerns:
1132
+
1133
+ * **Data models** (pure): `CollectionManifest`, `ExpressSchema`
1134
+ * **Value objects** (pure): `SchemaCategory`, `Urn`
1135
+ * **Services** (single concern): `ManifestTraverser`, `SchemaDiscovery`,
1136
+ `SchemaCompiler`, `SchemaExporter`, `SchemaIndex`, `LinkValidator`,
1137
+ `SchemaComparer`, `SchemaManifestGenerator`, `TermExtractor`,
1138
+ `RegisterManifestGenerator`, `Processor`
1139
+ * **Renderers** (pure, composable): `SchemaTemplate::Plain`,
1140
+ `SchemaTemplate::Document`
1141
+
1142
+ Key invariants:
1143
+
1144
+ * The classifier `ExpressSchema::Type.classify(id:, path:)` is the single
1145
+ source of truth for schema type. `SchemaCategory`, `SchemaNaming`,
1146
+ `SchemaExporter`, and `RegisterManifestGenerator` all derive from it.
1147
+ * `Urn` owns URN semantics (wildcard stripping, base/alias split, leaf
1148
+ composition).
1149
+ * `CollectionManifest` is a pure data model; tree walking lives in
1150
+ `ManifestTraverser`, schema I/O lives in `SchemaDiscovery`.
1151
+ * `SchemaCompiler` orchestrates one Metanorma compilation; the adoc body
1152
+ is injected via a `SchemaTemplate::*` renderer.
1153
+
1154
+ Code-style rules enforced throughout `lib/suma/`:
1155
+
1156
+ * Ruby `autoload` only — no `require_relative`
1157
+ * No `send` to call private methods
1158
+ * No `instance_variable_set` / `instance_variable_get`
1159
+ * No `respond_to?` for type checking
1160
+ * `Utils.log` writes to `$stderr`; debug level gated by `SUMA_DEBUG`
1161
+
1104
1162
 
1105
1163
  == Copyright and license
1106
1164
 
@@ -4,16 +4,19 @@ require "pathname"
4
4
 
5
5
  module Suma
6
6
  module Cli
7
- # Check SVG quality using svg_conform Validator API - thin CLI wrapper
7
+ # Check SVG quality. Thin adapter around +Suma::SvgQuality::Scanner+:
8
+ # argument parsing, file discovery, sorting, filtering, output
9
+ # formatting. The deep scanner module owns validation orchestration
10
+ # and is reachable from specs without invoking Thor.
8
11
  class CheckSvgQuality
9
12
  DATA_PATH = "schemas"
10
13
  DEFAULT_PATTERN = "**/*.svg"
11
14
  DEFAULT_PROFILE = :metanorma
12
15
 
13
16
  def initialize(pattern: DEFAULT_PATTERN, profile: DEFAULT_PROFILE,
14
- format: "terminal", output: nil, min_errors: nil,
15
- summary_only: false, progress: false, limit: nil,
16
- sort: "errors")
17
+ format: "terminal", output: nil, min_errors: nil,
18
+ summary_only: false, progress: false, limit: nil,
19
+ sort: "errors")
17
20
  @options = {
18
21
  pattern: pattern,
19
22
  profile: profile,
@@ -31,71 +34,42 @@ module Suma
31
34
  require "svg_conform"
32
35
 
33
36
  path_obj = Pathname.new(path).expand_path
34
-
35
- # Enable progress by default when outputting to terminal
36
37
  show_progress = options[:progress] || ($stdout.tty? && !options[:output])
37
- if show_progress
38
- $stdout.sync = true
39
- $stderr.sync = true
40
- end
41
-
42
- if path_obj.file?
43
- # Single file mode - show detailed errors
44
- analyze_single_file(path_obj)
45
- else
46
- # Directory mode - show batch report
47
- svg_files = find_svg_files(path_obj)
48
-
49
- if svg_files.empty?
50
- puts "No SVG files found in #{path}"
51
- return
52
- end
38
+ sync_stdio! if show_progress
53
39
 
54
- puts "🔍 Scanning #{svg_files.size} SVG files..."
55
- puts
40
+ files = discover_files(path_obj)
41
+ return if files.empty?
56
42
 
57
- reports = analyze_files_one_by_one(svg_files, show_progress)
58
- batch_report = SvgQuality::BatchReport.new(reports)
59
- sorted_report = sort_report(batch_report)
60
- output_report(sorted_report)
43
+ if single_file?(path_obj, files)
44
+ print_single_report(scan_single(files.first))
45
+ else
46
+ scan_and_output(files, show_progress)
61
47
  end
62
48
  end
63
49
 
64
- def analyze_single_file(path)
65
- validator = SvgConform::Validator.new
66
- result = validator.validate_file(path.to_s, profile: options[:profile])
67
-
68
- puts "📄 SVG Quality Report: #{path}"
69
- puts ""
70
- puts " Valid: #{result.valid? ? 'YES ✅' : 'NO ❌'}"
71
- puts " Errors: #{result.error_count}"
72
- puts ""
50
+ private
73
51
 
74
- if result.errors.any?
75
- puts " 📋 Error Details"
76
- puts ""
52
+ attr_reader :options
77
53
 
78
- # Group errors by requirement_id
79
- by_req = result.errors.group_by(&:requirement_id)
80
-
81
- by_req.each do |req_id, errors|
82
- puts " #{req_id} (#{errors.size} occurrences)"
83
- errors.first(5).each do |e|
84
- puts " - #{e.message}"
85
- end
86
- if errors.size > 5
87
- puts " ... and #{errors.size - 5} more"
88
- end
89
- puts ""
90
- end
91
- end
54
+ def single_file?(path_obj, files)
55
+ path_obj.file? && files.size == 1
92
56
  end
93
57
 
94
- private
58
+ def scan_and_output(files, show_progress)
59
+ puts "🔍 Scanning #{files.size} SVG files..." if show_progress
60
+ batch = SvgQuality::Scanner.new(
61
+ profile: options[:profile],
62
+ progress: progress_adapter(show_progress),
63
+ ).scan(files)
64
+ output_report(sort_report(batch))
65
+ end
95
66
 
96
- attr_reader :options
67
+ def sync_stdio!
68
+ $stdout.sync = true
69
+ $stderr.sync = true
70
+ end
97
71
 
98
- def find_svg_files(path)
72
+ def discover_files(path)
99
73
  if path.directory?
100
74
  Pathname.glob(path.join(options[:pattern])).select(&:file?)
101
75
  elsif path.file? && path.extname == ".svg"
@@ -105,34 +79,47 @@ module Suma
105
79
  end
106
80
  end
107
81
 
108
- def analyze_files_one_by_one(files, show_progress = false)
109
- validator = SvgConform::Validator.new
110
- reports = []
111
-
112
- files.each_with_index do |file, index|
113
- result = validator.validate_file(file.to_s,
114
- profile: options[:profile])
115
- report = SvgQuality::Report.new(file.to_s, result)
116
- reports << report
117
-
118
- if show_progress
119
- tier = report.quality_tier
120
- status = report.valid? ? "✅" : "❌"
121
- msg = " [#{index + 1}/#{files.size}] #{tier[:emoji]} #{report.error_count} errors #{status} #{shorten_path(file)}\n"
122
- $stderr.print msg
123
- $stderr.flush
124
- end
82
+ def scan_single(path)
83
+ SvgQuality::Scanner.new(profile: options[:profile]).scan_file(path)
84
+ end
85
+
86
+ def print_single_report(report)
87
+ result = report
88
+ puts "📄 SVG Quality Report: #{result.file_path}"
89
+ puts ""
90
+ puts " Valid: #{result.valid? ? 'YES ✅' : 'NO ❌'}"
91
+ puts " Errors: #{result.error_count}"
92
+ puts ""
93
+
94
+ return unless result.errors.any?
95
+
96
+ puts " 📋 Error Details"
97
+ puts ""
98
+ by_req = result.errors.group_by(&:requirement_id)
99
+ by_req.each do |req_id, errors|
100
+ puts " #{req_id} (#{errors.size} occurrences)"
101
+ errors.first(5).each { |e| puts " - #{e.message}" }
102
+ puts " ... and #{errors.size - 5} more" if errors.size > 5
103
+ puts ""
125
104
  end
105
+ end
106
+
107
+ def progress_adapter(enabled)
108
+ return SvgQuality::Scanner::NullProgress.new unless enabled
126
109
 
127
- reports
110
+ ->(_index, _total, report) do
111
+ tier = report.quality_tier
112
+ status = report.valid? ? "✅" : "❌"
113
+ $stderr.print " #{tier[:emoji]} #{report.error_count} errors " \
114
+ "#{status} #{shorten_path(report.file_path)}\n"
115
+ $stderr.flush
116
+ end
128
117
  end
129
118
 
130
119
  def sort_report(batch_report)
131
120
  case options[:sort]
132
- when :quality
133
- batch_report.sort_by_quality
134
- else
135
- batch_report.sort_by_errors
121
+ when :quality then batch_report.sort_by_quality
122
+ else batch_report.sort_by_errors
136
123
  end
137
124
  end
138
125
 
@@ -140,21 +127,25 @@ module Suma
140
127
  filtered = batch_report.filter_by_min_errors(options[:min_errors])
141
128
  limited = filtered.limit(options[:limit])
142
129
 
143
- formatter = case options[:format].to_sym
144
- when :json
145
- SvgQuality::Formatters::JsonFormatter.new(limited,
146
- output: options[:output])
147
- when :yaml
148
- SvgQuality::Formatters::YamlFormatter.new(limited,
149
- output: options[:output])
150
- else
151
- SvgQuality::Formatters::TerminalFormatter.new(limited,
152
- output: options[:output], sort: options[:sort])
153
- end
154
-
130
+ formatter = formatter_for(limited)
155
131
  puts formatter.format
156
132
  end
157
133
 
134
+ def formatter_for(batch_report)
135
+ case options[:format].to_sym
136
+ when :json
137
+ SvgQuality::Formatters::JsonFormatter.new(batch_report,
138
+ output: options[:output])
139
+ when :yaml
140
+ SvgQuality::Formatters::YamlFormatter.new(batch_report,
141
+ output: options[:output])
142
+ else
143
+ SvgQuality::Formatters::TerminalFormatter.new(batch_report,
144
+ output: options[:output],
145
+ sort: options[:sort])
146
+ end
147
+ end
148
+
158
149
  def shorten_path(path)
159
150
  p = Pathname.new(path)
160
151
  if p.absolute?
@@ -1,10 +1,19 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "thor"
4
+ require "pathname"
4
5
 
5
6
  module Suma
6
7
  module Cli
7
- # Export command for exporting EXPRESS schemas from a manifest
8
+ # Export command. Thin Thor adapter that constructs
9
+ # +Suma::ExpressSchema+ instances from manifest entries or
10
+ # standalone +.exp+ files, then delegates the actual writing to
11
+ # +Suma::SchemaExporter+.
12
+ #
13
+ # The schema-type → output-subdirectory mapping lives in
14
+ # +Suma::SchemaCategory+, the single source of truth. The exporter
15
+ # itself never classifies — it consumes loaded ExpressSchema
16
+ # objects whose output paths were set by this adapter.
8
17
  class Export < Thor
9
18
  desc "export *FILES",
10
19
  "Export EXPRESS schemas from manifest files or " \
@@ -38,7 +47,7 @@ module Suma
38
47
  end
39
48
 
40
49
  def run(files, options)
41
- schemas = load_schemas_from_files(files)
50
+ schemas = files.flat_map { |file| build_schemas(file) }
42
51
 
43
52
  exporter = SchemaExporter.new(
44
53
  schemas: schemas,
@@ -52,22 +61,12 @@ module Suma
52
61
  exporter.export
53
62
  end
54
63
 
55
- def load_schemas_from_files(files)
56
- all_schemas = []
57
-
58
- files.each do |file|
59
- all_schemas += process_file(file)
60
- end
61
-
62
- all_schemas
63
- end
64
-
65
- def process_file(file)
64
+ def build_schemas(file)
66
65
  case File.extname(file).downcase
67
66
  when ".yml", ".yaml"
68
- load_manifest_schemas(file)
67
+ build_from_manifest(file)
69
68
  when ".exp"
70
- [create_schema_from_exp_file(file)]
69
+ [build_standalone(file)]
71
70
  else
72
71
  raise ArgumentError, "Unsupported file type: #{file}. " \
73
72
  "Only .yml, .yaml, and .exp files are " \
@@ -75,13 +74,32 @@ module Suma
75
74
  end
76
75
  end
77
76
 
78
- def load_manifest_schemas(file)
77
+ def build_from_manifest(file)
79
78
  manifest = Expressir::SchemaManifest.from_file(file)
80
- manifest.schemas
79
+ manifest.schemas.map { |entry| build_from_manifest_entry(entry) }
80
+ end
81
+
82
+ def build_from_manifest_entry(entry)
83
+ category = SchemaCategory.for_schema(id: entry.id, path: entry.path)
84
+ ExpressSchema.new(
85
+ id: entry.id,
86
+ path: entry.path.to_s,
87
+ output_path: output_root.join(category.directory).to_s,
88
+ is_standalone_file: false,
89
+ )
90
+ end
91
+
92
+ def build_standalone(exp_file)
93
+ ExpressSchema.new(
94
+ id: nil,
95
+ path: File.expand_path(exp_file),
96
+ output_path: output_root.to_s,
97
+ is_standalone_file: true,
98
+ )
81
99
  end
82
100
 
83
- def create_schema_from_exp_file(exp_file)
84
- Struct.new(:id, :path).new(nil, File.expand_path(exp_file))
101
+ def output_root
102
+ Pathname.new(options[:output]).expand_path
85
103
  end
86
104
 
87
105
  def self.exit_on_failure?
@@ -4,84 +4,60 @@ require "thor"
4
4
 
5
5
  module Suma
6
6
  module Cli
7
- # Reformat command for reformatting EXPRESS files
7
+ # Reformat command for reformatting EXPRESS files.
8
+ #
9
+ # Thin Thor adapter around Suma::ExpressReformatter: argument
10
+ # parsing, file discovery, and read/transform/write. The content
11
+ # transformation itself lives in the deep module and is tested
12
+ # independently.
8
13
  class Reformat < Thor
9
- desc "reformat EXPRESS_FILE_PATH",
10
- "Reformat EXPRESS files"
14
+ desc "reformat EXPRESS_FILE_PATH", "Reformat EXPRESS files"
11
15
  option :recursive, type: :boolean, default: false, aliases: "-r",
12
16
  desc: "Reformat EXPRESS files under the specified " \
13
17
  "path recursively"
14
18
 
15
- def reformat(express_file_path) # rubocop:disable Metrics/AbcSize
16
- if File.file?(express_file_path)
17
- unless File.exist?(express_file_path)
18
- raise Errno::ENOENT, "Specified EXPRESS file " \
19
- "`#{express_file_path}` not found."
20
- end
21
-
22
- if File.extname(express_file_path) != ".exp"
23
- raise ArgumentError, "Specified file `#{express_file_path}` is " \
24
- "not an EXPRESS file."
25
- end
26
-
27
- exp_files = [express_file_path]
28
- elsif options[:recursive]
29
- exp_files = Dir.glob("#{express_file_path}/**/*.exp")
30
- else
31
- exp_files = Dir.glob("#{express_file_path}/*.exp")
32
- end
33
-
34
- if exp_files.empty?
35
- raise Errno::ENOENT, "No EXPRESS files found in " \
36
- "`#{express_file_path}`."
37
- end
38
-
39
- run(exp_files)
19
+ def reformat(express_file_path)
20
+ files = discover_files(express_file_path)
21
+ ensure_files_found!(files, express_file_path)
22
+ process_files(files)
40
23
  end
41
24
 
42
25
  private
43
26
 
44
- def run(exp_files)
45
- exp_files.each do |exp_file|
46
- reformat_exp(exp_file)
47
- end
48
- end
27
+ def discover_files(path)
28
+ return [path] if File.file?(path)
29
+ return Dir.glob("#{path}/**/*.exp") if options[:recursive]
49
30
 
50
- def reformat_exp(file) # rubocop:disable Metrics/AbcSize
51
- # Read the file content
52
- file_content = File.read(file)
53
-
54
- # Extract all comments between '(*"' and '\n*)'
55
- # Avoid incorrect selection of some comment blocks
56
- # containing '(*text*)' inside
57
- comments = file_content.scan(/\(\*"(.*?)\n\*\)/m).map(&:first)
58
-
59
- if comments.any?
60
- content_without_comments = file_content.gsub(/\(\*".*?\n\*\)/m, "")
61
-
62
- # remove extra newlines
63
- new_content = content_without_comments.gsub(/(\n\n+)/, "\n\n")
64
- # Add '(*"' and '\n*)' to enclose the comment block
65
- new_comments = comments.map { |c| "(*\"#{c}\n*)" }.join("\n\n")
66
- # Append the comments to the end of the file
67
- new_content = "#{new_content}\n\n#{new_comments}\n"
31
+ Dir.glob("#{path}/*.exp")
32
+ end
68
33
 
69
- # Compare the changes between the original content with the modified
70
- # content, if the changes are just whitespaces, skip modifying the
71
- # file
72
- if file_content.gsub(/(\n+)/, "\n") == new_content.gsub(/(\n+)/, "\n")
73
- puts "No changes made to #{file}"
74
- return
34
+ def ensure_files_found!(files, path)
35
+ if File.file?(path)
36
+ unless File.extname(path) == ".exp"
37
+ raise ArgumentError,
38
+ "Specified file `#{path}` is not an EXPRESS file."
75
39
  end
76
-
77
- update_exp(file, new_content)
40
+ elsif !File.exist?(path)
41
+ raise Errno::ENOENT,
42
+ "Specified EXPRESS file `#{path}` not found."
78
43
  end
44
+ return unless files.empty?
45
+
46
+ raise Errno::ENOENT, "No EXPRESS files found in `#{path}`."
47
+ end
48
+
49
+ def process_files(files)
50
+ files.each { |file| reformat_one(file) }
79
51
  end
80
52
 
81
- def update_exp(file, content)
82
- # Write the modified content to a new file
83
- File.write(file, content)
84
- puts "Reformatted EXPRESS file and saved to #{file}"
53
+ def reformat_one(file)
54
+ result = ExpressReformatter.call(File.read(file))
55
+ if result.changed?
56
+ File.write(file, result.content)
57
+ puts "Reformatted EXPRESS file and saved to #{file}"
58
+ else
59
+ puts "No changes made to #{file}"
60
+ end
85
61
  end
86
62
  end
87
63
  end
@@ -4,14 +4,23 @@ require "thor"
4
4
 
5
5
  module Suma
6
6
  module Cli
7
- # Main validate command that groups the validation subcommands
7
+ # Validate command group. Thin Thor adapter around
8
+ # +Suma::LinkValidation+ — argument parsing, result presentation.
9
+ # All orchestration (manifest loading, link extraction, schema
10
+ # indexing, validation) lives in the deep module and is reachable
11
+ # from specs without invoking Thor.
8
12
  class Validate < Thor
9
13
  desc "links SCHEMAS_FILE DOCUMENTS_PATH [OUTPUT_FILE]",
10
14
  "Extract and validate express links without creating intermediate file"
11
- def links(*args)
12
- # Forward the command to ValidateLinks
13
- links = Cli::ValidateLinks.new
14
- links.extract_and_validate(*args)
15
+ def links(schemas_file = "schemas-srl.yml",
16
+ documents_path = "documents",
17
+ output_file = "validation_results.txt")
18
+ result = LinkValidation.new(
19
+ schemas_file: schemas_file,
20
+ documents_path: documents_path,
21
+ output_file: output_file,
22
+ ).call
23
+ puts LinkValidation.generate_summary(result)
15
24
  end
16
25
  end
17
26
  end