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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +10 -0
- data/LICENSE +21 -0
- data/README.md +82 -0
- data/SECURITY.md +40 -0
- data/docs/active-record-association-support.md +37 -0
- data/docs/assets/branding/rails-mmd-social-preview.jpg +0 -0
- data/docs/usage.md +126 -0
- data/exe/rails-mmd +6 -0
- data/fixtures/schemas/diagnostics/valid/catalog.json +39 -0
- data/lib/rails_mmd/artifact_ref_sanitizer.rb +59 -0
- data/lib/rails_mmd/attribute_types.rb +29 -0
- data/lib/rails_mmd/canonical_json.rb +19 -0
- data/lib/rails_mmd/cli.rb +86 -0
- data/lib/rails_mmd/config.rb +249 -0
- data/lib/rails_mmd/constant_resolver.rb +33 -0
- data/lib/rails_mmd/diagnostic_factory.rb +122 -0
- data/lib/rails_mmd/domain_resolver.rb +97 -0
- data/lib/rails_mmd/exit_policy.rb +27 -0
- data/lib/rails_mmd/generate_command.rb +116 -0
- data/lib/rails_mmd/generation_pipeline.rb +109 -0
- data/lib/rails_mmd/ir_builder.rb +122 -0
- data/lib/rails_mmd/mermaid_serializer.rb +168 -0
- data/lib/rails_mmd/metadata_shapes.rb +55 -0
- data/lib/rails_mmd/model_inventory.rb +144 -0
- data/lib/rails_mmd/ordering.rb +25 -0
- data/lib/rails_mmd/output_directory.rb +192 -0
- data/lib/rails_mmd/publisher.rb +166 -0
- data/lib/rails_mmd/rails_loader.rb +96 -0
- data/lib/rails_mmd/redactor.rb +143 -0
- data/lib/rails_mmd/relationship_builder.rb +982 -0
- data/lib/rails_mmd/render_plan_builder.rb +192 -0
- data/lib/rails_mmd/safe_token_assigner.rb +130 -0
- data/lib/rails_mmd/schema_probe.rb +376 -0
- data/lib/rails_mmd/schema_validator.rb +136 -0
- data/lib/rails_mmd/subject_ids.rb +17 -0
- data/lib/rails_mmd/tooling_tasks.rb +47 -0
- data/lib/rails_mmd/verification_runner.rb +143 -0
- data/lib/rails_mmd/version.rb +5 -0
- data/lib/rails_mmd.rb +15 -0
- data/schemas/config.schema.json +54 -0
- data/schemas/diagnostics.schema.json +418 -0
- data/schemas/ir.schema.json +84 -0
- data/schemas/render_plan.schema.json +136 -0
- 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
|