rails-mmd 0.1.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.
Files changed (45) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +10 -0
  3. data/LICENSE +21 -0
  4. data/README.md +82 -0
  5. data/SECURITY.md +40 -0
  6. data/docs/active-record-association-support.md +37 -0
  7. data/docs/assets/branding/rails-mmd-social-preview.jpg +0 -0
  8. data/docs/usage.md +126 -0
  9. data/exe/rails-mmd +6 -0
  10. data/fixtures/schemas/diagnostics/valid/catalog.json +39 -0
  11. data/lib/rails_mmd/artifact_ref_sanitizer.rb +59 -0
  12. data/lib/rails_mmd/attribute_types.rb +29 -0
  13. data/lib/rails_mmd/canonical_json.rb +19 -0
  14. data/lib/rails_mmd/cli.rb +86 -0
  15. data/lib/rails_mmd/config.rb +249 -0
  16. data/lib/rails_mmd/constant_resolver.rb +33 -0
  17. data/lib/rails_mmd/diagnostic_factory.rb +122 -0
  18. data/lib/rails_mmd/domain_resolver.rb +97 -0
  19. data/lib/rails_mmd/exit_policy.rb +27 -0
  20. data/lib/rails_mmd/generate_command.rb +116 -0
  21. data/lib/rails_mmd/generation_pipeline.rb +109 -0
  22. data/lib/rails_mmd/ir_builder.rb +122 -0
  23. data/lib/rails_mmd/mermaid_serializer.rb +168 -0
  24. data/lib/rails_mmd/metadata_shapes.rb +55 -0
  25. data/lib/rails_mmd/model_inventory.rb +144 -0
  26. data/lib/rails_mmd/ordering.rb +25 -0
  27. data/lib/rails_mmd/output_directory.rb +192 -0
  28. data/lib/rails_mmd/publisher.rb +166 -0
  29. data/lib/rails_mmd/rails_loader.rb +96 -0
  30. data/lib/rails_mmd/redactor.rb +143 -0
  31. data/lib/rails_mmd/relationship_builder.rb +982 -0
  32. data/lib/rails_mmd/render_plan_builder.rb +192 -0
  33. data/lib/rails_mmd/safe_token_assigner.rb +130 -0
  34. data/lib/rails_mmd/schema_probe.rb +376 -0
  35. data/lib/rails_mmd/schema_validator.rb +136 -0
  36. data/lib/rails_mmd/subject_ids.rb +17 -0
  37. data/lib/rails_mmd/tooling_tasks.rb +47 -0
  38. data/lib/rails_mmd/verification_runner.rb +143 -0
  39. data/lib/rails_mmd/version.rb +5 -0
  40. data/lib/rails_mmd.rb +15 -0
  41. data/schemas/config.schema.json +54 -0
  42. data/schemas/diagnostics.schema.json +418 -0
  43. data/schemas/ir.schema.json +84 -0
  44. data/schemas/render_plan.schema.json +136 -0
  45. metadata +135 -0
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'rails_mmd/diagnostic_factory'
5
+ require 'rails_mmd/output_directory'
6
+ require 'rails_mmd/redactor'
7
+ require 'rails_mmd/schema_validator'
8
+
9
+ module RailsMmd
10
+ # Loads and resolves the P0 YAML configuration contract without exposing CLI routing.
11
+ # rubocop:disable-next Metrics/ClassLength, Metrics/AbcSize, Metrics/MethodLength
12
+ class Config
13
+ DEFAULT_CONFIG_PATH = 'rails_mmd.yml'
14
+ DEFAULT_OUTPUT = {
15
+ 'directory' => 'docs/rails_mmd',
16
+ 'format' => 'both',
17
+ 'attributes' => 'keys',
18
+ 'direction' => 'LR'
19
+ }.freeze
20
+ DOMAIN_ID_PATTERN = /\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\z/
21
+ VALID_FORMATS = %w[er class both].freeze
22
+ EXIT_CONTRACT_ERROR = 2
23
+
24
+ CliOptions = Struct.new(:config_path, :output_dir, :domain, :format, keyword_init: true)
25
+ Domain = Struct.new(:domain_id, :include_models, :exclude_models, keyword_init: true)
26
+ Output = Struct.new(:directory, :path, :format, :attributes, :direction, :sources, keyword_init: true)
27
+ Resolved = Struct.new(:config_path, :domains, :output, :selected_domain_ids, keyword_init: true)
28
+ Result = Struct.new(:config, :diagnostics, :exit_code, keyword_init: true) do
29
+ def success?
30
+ diagnostics.empty?
31
+ end
32
+ end
33
+
34
+ def initialize(project_root: Dir.pwd, validator: SchemaValidator.new, redactor: nil)
35
+ @project_root = Pathname(project_root).expand_path
36
+ @validator = validator
37
+ @redactor = redactor || Redactor.new(project_root: @project_root)
38
+ @diagnostic_factory = DiagnosticFactory.new(redactor: @redactor)
39
+ @output_directory = OutputDirectory.new(project_root: @project_root)
40
+ end
41
+
42
+ def load(cli_options: CliOptions.new)
43
+ config_path = resolve_config_path(cli_options.config_path)
44
+ return failure(config_not_found(config_path)) unless config_path.file?
45
+
46
+ data = safe_load(config_path)
47
+ return failure(config_schema_invalid(config_path, 'config', 'must be a YAML mapping')) unless data.is_a?(Hash)
48
+
49
+ output_directory_error = configured_output_directory_error(data, cli_options)
50
+ return failure(output_directory_error) if output_directory_error
51
+
52
+ schema_errors = validator.errors(:config, data_for_schema_validation(data, cli_options))
53
+ unless schema_errors.empty?
54
+ return failure(config_schema_invalid(config_path, field_path(schema_errors.first),
55
+ 'does not match config schema'))
56
+ end
57
+
58
+ build_config(config_path, data, cli_options)
59
+ rescue Psych::Exception => e
60
+ failure(config_schema_invalid(config_path, 'config', e.message))
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :diagnostic_factory, :output_directory, :project_root, :validator
66
+
67
+ def build_config(config_path, data, cli_options)
68
+ domains = build_domains(data.fetch('domains'))
69
+ output = build_output(data.fetch('output', {}), cli_options)
70
+ unless output.fetch(:directory_result).valid?
71
+ return failure(output_directory_invalid(output.fetch(:directory_result),
72
+ output.fetch(:directory_source)))
73
+ end
74
+ unless VALID_FORMATS.include?(output.fetch(:format))
75
+ return failure(config_schema_invalid(config_path, '$.cli.format',
76
+ 'must be er, class, or both'))
77
+ end
78
+
79
+ unless valid_domain_selection?(cli_options.domain)
80
+ return failure(config_schema_invalid(config_path, '$.cli.domain',
81
+ 'must match domain ID grammar'))
82
+ end
83
+
84
+ selected_domain_ids = selected_domain_ids(domains, cli_options.domain)
85
+ if selected_domain_ids.nil?
86
+ return failure(diagnostic_factory.build(
87
+ code: 'CONFIG_DOMAIN_NOT_FOUND',
88
+ message: "Unknown domain #{cli_options.domain}",
89
+ subject_id: "domain:#{cli_options.domain}",
90
+ metadata: { domain_id: cli_options.domain.to_s }
91
+ ))
92
+ end
93
+
94
+ success(config_path, domains, output, selected_domain_ids)
95
+ end
96
+
97
+ def build_domains(domain_data)
98
+ domain_data.to_h do |domain_id, domain|
99
+ [
100
+ domain_id,
101
+ Domain.new(
102
+ domain_id: domain_id,
103
+ include_models: domain.fetch('include_models'),
104
+ exclude_models: domain.fetch('exclude_models', [])
105
+ )
106
+ ]
107
+ end
108
+ end
109
+
110
+ def build_output(data, cli_options)
111
+ directory_source = cli_options.output_dir.nil? ? '$.output.directory' : '$.cli.output_dir'
112
+ directory = cli_options.output_dir || data.fetch('directory', DEFAULT_OUTPUT.fetch('directory'))
113
+ format = cli_options.format || data.fetch('format', DEFAULT_OUTPUT.fetch('format'))
114
+
115
+ {
116
+ directory_result: output_directory.resolve(directory),
117
+ directory_source: directory_source,
118
+ format: format,
119
+ attributes: data.fetch('attributes', DEFAULT_OUTPUT.fetch('attributes')),
120
+ direction: data.fetch('direction', DEFAULT_OUTPUT.fetch('direction')),
121
+ sources: {
122
+ 'directory' => source_for(cli_options.output_dir, data, 'directory'),
123
+ 'format' => source_for(cli_options.format, data, 'format'),
124
+ 'attributes' => data.key?('attributes') ? 'config' : 'default',
125
+ 'direction' => data.key?('direction') ? 'config' : 'default'
126
+ }
127
+ }
128
+ end
129
+
130
+ def selected_domain_ids(domains, selected_domain)
131
+ return domains.keys if selected_domain.nil?
132
+ return [selected_domain] if domains.key?(selected_domain)
133
+
134
+ nil
135
+ end
136
+
137
+ def success(config_path, domains, output_data, selected_domain_ids)
138
+ directory = output_data.fetch(:directory_result)
139
+ output = Output.new(
140
+ directory: directory.relative_path,
141
+ path: directory.path,
142
+ format: output_data.fetch(:format),
143
+ attributes: output_data.fetch(:attributes),
144
+ direction: output_data.fetch(:direction),
145
+ sources: output_data.fetch(:sources)
146
+ )
147
+ config = Resolved.new(config_path: config_path, domains: domains, output: output,
148
+ selected_domain_ids: selected_domain_ids)
149
+
150
+ Result.new(config: config, diagnostics: [], exit_code: 0)
151
+ end
152
+
153
+ def failure(diagnostic)
154
+ Result.new(config: nil, diagnostics: [diagnostic], exit_code: EXIT_CONTRACT_ERROR)
155
+ end
156
+
157
+ def config_not_found(config_path)
158
+ diagnostic_factory.build(
159
+ code: 'CONFIG_NOT_FOUND',
160
+ message: "Config file not found: #{display_path(config_path)}",
161
+ subject_id: 'config',
162
+ metadata: { config_path: display_path(config_path) }
163
+ )
164
+ end
165
+
166
+ def config_schema_invalid(config_path, field_path_value, reason)
167
+ diagnostic_factory.build(
168
+ code: 'CONFIG_SCHEMA_INVALID',
169
+ message: "Invalid config #{display_path(config_path)}: #{reason}",
170
+ subject_id: 'config',
171
+ metadata: { config_path: display_path(config_path), field_path: field_path_value }
172
+ )
173
+ end
174
+
175
+ def output_directory_invalid(result, field_path_value)
176
+ diagnostic_factory.build(
177
+ code: 'OUTPUT_DIRECTORY_INVALID',
178
+ message: "Invalid output directory: #{result.reason}",
179
+ subject_id: 'output.directory',
180
+ metadata: { field_path: field_path_value, reason: result.reason }
181
+ )
182
+ end
183
+
184
+ def data_for_schema_validation(data, cli_options)
185
+ data = deep_dup(data)
186
+ output = data['output']
187
+ return data unless output.is_a?(Hash)
188
+
189
+ output.delete('directory') unless cli_options.output_dir.nil?
190
+ output.delete('format') unless cli_options.format.nil?
191
+ data
192
+ end
193
+
194
+ def configured_output_directory_error(data, cli_options)
195
+ return if cli_options.output_dir
196
+
197
+ output = data['output']
198
+ return unless output.is_a?(Hash) && output.key?('directory') && output.fetch('directory').is_a?(String)
199
+
200
+ result = output_directory.resolve(output.fetch('directory'))
201
+ output_directory_invalid(result, '$.output.directory') unless result.valid?
202
+ end
203
+
204
+ def resolve_config_path(value)
205
+ Pathname(value || DEFAULT_CONFIG_PATH).then do |path|
206
+ path.absolute? ? path : project_root.join(path)
207
+ end
208
+ end
209
+
210
+ def display_path(path)
211
+ relative = Pathname(path).expand_path.relative_path_from(project_root).to_s
212
+ return relative unless relative.start_with?('..')
213
+
214
+ DEFAULT_CONFIG_PATH
215
+ end
216
+
217
+ def safe_load(path)
218
+ YAML.safe_load(path.read, permitted_classes: [], permitted_symbols: [], aliases: false)
219
+ end
220
+
221
+ def field_path(error)
222
+ pointer = error&.fetch('data_pointer', nil).to_s
223
+ return '$' if pointer.empty?
224
+
225
+ "$.#{pointer.delete_prefix('/').tr('/', '.')}"
226
+ end
227
+
228
+ def source_for(cli_value, config_data, key)
229
+ return 'cli' unless cli_value.nil?
230
+
231
+ config_data.key?(key) ? 'config' : 'default'
232
+ end
233
+
234
+ def valid_domain_selection?(domain)
235
+ domain.nil? || domain.match?(DOMAIN_ID_PATTERN)
236
+ end
237
+
238
+ def deep_dup(value)
239
+ case value
240
+ when Hash
241
+ value.transform_values { |item| deep_dup(item) }
242
+ when Array
243
+ value.map { |item| deep_dup(item) }
244
+ else
245
+ value
246
+ end
247
+ end
248
+ end
249
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMmd
4
+ # Resolves exact Ruby constants while containing ordinary autoload failures.
5
+ # @api private
6
+ class ConstantResolver
7
+ def self.wrap(resolver)
8
+ return resolver if resolver.is_a?(self)
9
+ return new(loader: resolver.method(:resolve)) if resolver.respond_to?(:resolve)
10
+ return new(loader: resolver) if resolver.respond_to?(:call)
11
+
12
+ raise ArgumentError, 'constant resolver must respond to resolve or call'
13
+ end
14
+
15
+ def initialize(loader: method(:load_constant))
16
+ @loader = loader
17
+ end
18
+
19
+ def resolve(name)
20
+ loader.call(name)
21
+ rescue ScriptError, StandardError
22
+ nil
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :loader
28
+
29
+ def load_constant(name)
30
+ name.split('::').reduce(Object) { |namespace, const_name| namespace.const_get(const_name, false) }
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rails_mmd/artifact_ref_sanitizer'
5
+ require 'rails_mmd/canonical_json'
6
+ require 'rails_mmd/metadata_shapes'
7
+ require 'rails_mmd/redactor'
8
+ require 'rails_mmd/subject_ids'
9
+
10
+ module RailsMmd
11
+ # Schema-backed diagnostic object factory.
12
+ class DiagnosticFactory
13
+ CATALOG_PATH = Pathname(__dir__).join('../../fixtures/schemas/diagnostics/valid/catalog.json').expand_path
14
+ SCHEMA_PATH = Pathname(__dir__).join('../../schemas/diagnostics.schema.json').expand_path
15
+ FREE_TEXT_REFS = %w[#/$defs/relative_path #/$defs/sanitized_string].freeze
16
+
17
+ def initialize(redactor: Redactor.new, artifact_refs: ArtifactRefSanitizer.new(redactor: redactor))
18
+ @redactor = redactor
19
+ @artifact_refs = artifact_refs
20
+ end
21
+
22
+ def build(code:, message:, **options)
23
+ template = self.class.catalog.fetch(code)
24
+ diagnostic = diagnostic_payload(code, message, template, options)
25
+ validate_metadata_keys!(code, diagnostic.fetch('metadata'))
26
+ self.class.metadata_shapes.validate!(code, diagnostic.fetch('metadata'))
27
+ diagnostic
28
+ end
29
+
30
+ def diagnostic_payload(code, message, template, options)
31
+ metadata = options.fetch(:metadata, {})
32
+ content = diagnostic_content(code, message, options, metadata)
33
+ diagnostic_defaults(code, template, options, content).merge(content)
34
+ end
35
+
36
+ def diagnostic_defaults(code, template, options, content)
37
+ {
38
+ 'diagnostic_id' => diagnostic_id(code, content.fetch('subject_id'), content.fetch('metadata')),
39
+ 'code' => code,
40
+ 'severity' => options[:severity] || template.fetch('severity'),
41
+ 'phase' => template.fetch('phase'),
42
+ 'scope' => template.fetch('scope')
43
+ }
44
+ end
45
+
46
+ def diagnostic_content(code, message, options, metadata)
47
+ {
48
+ 'subject_id' => SubjectIds.normalize(options[:subject_id], code: code),
49
+ 'message' => redactor.sanitize(message),
50
+ 'metadata' => sanitized_metadata(code, metadata),
51
+ 'artifact_refs' => artifact_refs.sanitize(options.fetch(:artifact_refs, [])),
52
+ 'remediation' => options[:remediation] && redactor.sanitize_object(options[:remediation])
53
+ }
54
+ end
55
+
56
+ def exception_metadata(exception)
57
+ {
58
+ 'exception_class' => exception.class.name,
59
+ 'exception_summary' => redactor.sanitize(exception.message),
60
+ 'backtrace' => nil
61
+ }
62
+ end
63
+
64
+ def self.codes
65
+ catalog.keys
66
+ end
67
+
68
+ def self.catalog
69
+ @catalog ||= JSON.parse(CATALOG_PATH.read).fetch('diagnostics').to_h do |diagnostic|
70
+ [diagnostic.fetch('code'), diagnostic]
71
+ end
72
+ end
73
+
74
+ def self.metadata_shapes
75
+ @metadata_shapes ||= MetadataShapes.new(schema_path: SCHEMA_PATH)
76
+ end
77
+
78
+ private
79
+
80
+ attr_reader :artifact_refs, :redactor
81
+
82
+ def diagnostic_id(code, subject_id, metadata)
83
+ digest = CanonicalJson.digest_sha256('code' => code, 'subject_id' => subject_id, 'metadata' => metadata)
84
+ "diag_#{digest[0, 16]}"
85
+ end
86
+
87
+ def sanitized_metadata(code, metadata)
88
+ shape = self.class.metadata_shapes.fetch(code)
89
+ template_keys = shape.fetch('properties').keys
90
+ metadata = stringify_keys(metadata)
91
+ raise ArgumentError, "unknown diagnostic metadata for #{code}" unless (metadata.keys - template_keys).empty?
92
+
93
+ metadata.transform_values.with_index do |value, index|
94
+ sanitize_metadata_value(metadata.keys.fetch(index), value)
95
+ end
96
+ end
97
+
98
+ def sanitize_metadata_value(key, value)
99
+ return value unless metadata_free_text?(key)
100
+ raise ArgumentError, "invalid diagnostic metadata value for #{key}" unless value.is_a?(String)
101
+
102
+ redactor.sanitize(value)
103
+ end
104
+
105
+ def metadata_free_text?(key)
106
+ FREE_TEXT_REFS.include?(self.class.metadata_shapes.properties.fetch(key).fetch('$ref', nil))
107
+ end
108
+
109
+ def validate_metadata_keys!(code, metadata)
110
+ required_keys = self.class.metadata_shapes.fetch(code).fetch('required')
111
+ missing_keys = required_keys - metadata.keys
112
+ return if missing_keys.empty?
113
+
114
+ raise ArgumentError,
115
+ "missing diagnostic metadata for #{code}: #{missing_keys.join(', ')}"
116
+ end
117
+
118
+ def stringify_keys(value)
119
+ value.to_h.transform_keys(&:to_s)
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_mmd/diagnostic_factory'
4
+
5
+ module RailsMmd
6
+ # Resolves configured domains exactly against schema-free inventory records.
7
+ class DomainResolver
8
+ DomainResult = Struct.new(:domain_id, :records, :diagnostics, keyword_init: true)
9
+ Result = Struct.new(:domains, :diagnostics, keyword_init: true)
10
+
11
+ def initialize(diagnostics: DiagnosticFactory.new)
12
+ @diagnostic_factory = diagnostics
13
+ end
14
+
15
+ def resolve(config:, inventory_records:)
16
+ records_by_constant = inventory_records.to_h { |record| [record.ruby_constant, record] }
17
+ domain_results = config.selected_domain_ids.map do |domain_id|
18
+ resolve_domain(config.domains.fetch(domain_id), records_by_constant)
19
+ end
20
+ all_diagnostics = domain_results.flat_map(&:diagnostics)
21
+
22
+ Result.new(domains: domain_results, diagnostics: all_diagnostics)
23
+ end
24
+
25
+ private
26
+
27
+ attr_reader :diagnostic_factory
28
+
29
+ def resolve_domain(domain, records_by_constant)
30
+ include_records, include_diagnostics = resolve_model_list(domain, domain.include_models, records_by_constant)
31
+ exclude_records, exclude_diagnostics = resolve_model_list(domain, domain.exclude_models, records_by_constant)
32
+ selected_records = selected_records(include_records, exclude_records)
33
+ empty_diagnostic = selected_records.empty? ? [domain_empty(domain.domain_id)] : []
34
+
35
+ DomainResult.new(
36
+ domain_id: domain.domain_id,
37
+ records: selected_records,
38
+ diagnostics: include_diagnostics + exclude_diagnostics + empty_diagnostic
39
+ )
40
+ end
41
+
42
+ def selected_records(include_records, exclude_records)
43
+ excluded = exclude_records.to_set(&:ruby_constant)
44
+ seen = Set.new
45
+ include_records.each_with_object([]) do |record, selected|
46
+ next if excluded.include?(record.ruby_constant) || seen.include?(record.ruby_constant)
47
+
48
+ seen << record.ruby_constant
49
+ selected << record
50
+ end
51
+ end
52
+
53
+ def resolve_model_list(domain, ruby_constants, records_by_constant)
54
+ ruby_constants.each_with_object([[], []]) do |ruby_constant, (records, output_diagnostics)|
55
+ record = records_by_constant[ruby_constant]
56
+ if record.nil?
57
+ output_diagnostics << model_not_found(domain.domain_id, ruby_constant)
58
+ elsif record.renderable
59
+ records << record
60
+ else
61
+ output_diagnostics << model_not_renderable(domain.domain_id, record)
62
+ end
63
+ end
64
+ end
65
+
66
+ def model_not_found(domain_id, ruby_constant)
67
+ diagnostic_factory.build(
68
+ code: 'DOMAIN_MODEL_NOT_FOUND',
69
+ message: "Domain #{domain_id} references missing model #{ruby_constant}",
70
+ subject_id: "#{domain_id}:#{ruby_constant}",
71
+ metadata: { domain_id: domain_id, ruby_constant: ruby_constant }
72
+ )
73
+ end
74
+
75
+ def model_not_renderable(domain_id, record)
76
+ diagnostic_factory.build(
77
+ code: 'DOMAIN_MODEL_NOT_RENDERABLE',
78
+ message: "Domain #{domain_id} references non-renderable model #{record.ruby_constant}",
79
+ subject_id: "#{domain_id}:#{record.ruby_constant}",
80
+ metadata: {
81
+ domain_id: domain_id,
82
+ ruby_constant: record.ruby_constant,
83
+ renderability_reason: record.renderability_reason
84
+ }
85
+ )
86
+ end
87
+
88
+ def domain_empty(domain_id)
89
+ diagnostic_factory.build(
90
+ code: 'DOMAIN_EMPTY',
91
+ message: "Domain #{domain_id} has no renderable models",
92
+ subject_id: domain_id,
93
+ metadata: { domain_id: domain_id }
94
+ )
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMmd
4
+ # Maps diagnostics to the public P0 process exit contract.
5
+ module ExitPolicy
6
+ EXIT_CODE_BY_DIAGNOSTIC_CODE = {
7
+ 'MERMAID_SERIALIZATION_FAILED' => 3,
8
+ 'SAFE_TOKEN_COLLISION' => 3,
9
+ 'OUTPUT_WRITE_FAILED' => 4,
10
+ 'INTERNAL_ERROR' => 99
11
+ }.freeze
12
+ BLOCKING_SEVERITIES = %w[fatal error].freeze
13
+
14
+ module_function
15
+
16
+ def exit_code(diagnostics, fail_on_warning: false)
17
+ blocking = diagnostics.select { |diagnostic| BLOCKING_SEVERITIES.include?(diagnostic.fetch('severity')) }
18
+ if blocking.empty?
19
+ return 1 if fail_on_warning && diagnostics.any? { |diagnostic| diagnostic.fetch('severity') == 'warning' }
20
+
21
+ return 0
22
+ end
23
+
24
+ blocking.map { |diagnostic| EXIT_CODE_BY_DIAGNOSTIC_CODE.fetch(diagnostic.fetch('code'), 2) }.max
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_mmd/config'
4
+ require 'rails_mmd/exit_policy'
5
+ require 'rails_mmd/generation_pipeline'
6
+ require 'rails_mmd/publisher'
7
+ require 'rails_mmd/rails_loader'
8
+ require 'rails_mmd/redactor'
9
+
10
+ module RailsMmd
11
+ # Coordinates the P0 generate pipeline behind the Thor command.
12
+ # @api private
13
+ class GenerateCommand
14
+ Result = Struct.new(:exit_code, :stdout, :stderr, :diagnostics, keyword_init: true) do
15
+ def success?
16
+ exit_code.zero?
17
+ end
18
+ end
19
+
20
+ def initialize(project_root: Dir.pwd, rails_loader: nil, pipeline: nil)
21
+ @project_root = Pathname(project_root).expand_path
22
+ @rails_loader = rails_loader
23
+ @pipeline = pipeline
24
+ end
25
+
26
+ def run(cli_options:, fail_on_warning: false)
27
+ config_result = Config.new(project_root: project_root, redactor: redactor).load(cli_options: cli_options)
28
+ return pre_output_result(config_result.diagnostics, config_result.exit_code) unless config_result.success?
29
+
30
+ run_pipeline(config_result.config, fail_on_warning)
31
+ rescue StandardError => e
32
+ internal_error_result(e)
33
+ end
34
+
35
+ private
36
+
37
+ attr_reader :project_root
38
+
39
+ def run_pipeline(config, fail_on_warning)
40
+ rails_result = rails_loader.boot
41
+ return pre_output_result(rails_result.diagnostics, rails_result.exit_code) unless rails_result.success?
42
+
43
+ pipeline_result = generate_artifacts(config)
44
+ publish_result(config, pipeline_result.diagnostics, pipeline_result.artifacts, fail_on_warning)
45
+ end
46
+
47
+ def publish_result(config, diagnostics, artifacts, fail_on_warning)
48
+ publication_result = publisher.publish(
49
+ output_dir: config.output.directory,
50
+ selected_domain_ids: config.selected_domain_ids,
51
+ diagnostics: diagnostics,
52
+ artifacts: artifacts
53
+ )
54
+ command_result_for(publication_result, fail_on_warning)
55
+ end
56
+
57
+ def command_result_for(publication_result, fail_on_warning)
58
+ Result.new(
59
+ exit_code: publication_exit_code(publication_result, fail_on_warning),
60
+ stdout: '',
61
+ stderr: publication_stderr(publication_result),
62
+ diagnostics: publication_result.diagnostics
63
+ )
64
+ end
65
+
66
+ def publication_exit_code(publication_result, fail_on_warning)
67
+ ExitPolicy.exit_code(publication_result.diagnostics, fail_on_warning: fail_on_warning)
68
+ end
69
+
70
+ def publication_stderr(publication_result)
71
+ return publication_result.stderr.to_s unless publication_result.stderr.to_s.empty?
72
+ return '' if publication_result.success?
73
+
74
+ publisher.pre_output_stderr(publication_result.diagnostics)
75
+ end
76
+
77
+ def pre_output_result(diagnostics, exit_code)
78
+ Result.new(exit_code: exit_code, stdout: '', stderr: publisher.pre_output_stderr(diagnostics),
79
+ diagnostics: diagnostics)
80
+ end
81
+
82
+ def internal_error_result(exception)
83
+ diagnostic = diagnostics_factory.build(
84
+ code: 'INTERNAL_ERROR',
85
+ message: 'internal error',
86
+ metadata: diagnostics_factory.exception_metadata(exception)
87
+ )
88
+ Result.new(exit_code: 99, stdout: '', stderr: publisher.pre_output_stderr([diagnostic]),
89
+ diagnostics: [diagnostic])
90
+ end
91
+
92
+ def rails_loader
93
+ @rails_loader ||= RailsLoader.new(project_root: project_root, diagnostics: diagnostics_factory)
94
+ end
95
+
96
+ def publisher
97
+ @publisher ||= Publisher.new(project_root: project_root, diagnostics_factory: diagnostics_factory)
98
+ end
99
+
100
+ def diagnostics_factory
101
+ @diagnostics_factory ||= DiagnosticFactory.new(redactor: redactor)
102
+ end
103
+
104
+ def redactor
105
+ @redactor ||= Redactor.new(project_root: project_root)
106
+ end
107
+
108
+ def pipeline
109
+ @pipeline ||= GenerationPipeline.new(redactor: redactor)
110
+ end
111
+
112
+ def generate_artifacts(config)
113
+ pipeline.generate(config: config)
114
+ end
115
+ end
116
+ end