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,192 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+ require 'pathname'
5
+ require 'tmpdir'
6
+
7
+ module RailsMmd
8
+ # Validates and resolves project-root-relative output directories.
9
+ # rubocop:disable-next Metrics/ClassLength
10
+ class OutputDirectory
11
+ Resolution = Struct.new(:path, :relative_path, :reason, keyword_init: true) do
12
+ def valid?
13
+ reason.nil?
14
+ end
15
+ end
16
+ DRIVE_OR_UNC_PATTERN = /\A(?:[A-Za-z]:|\\\\)/
17
+ SAFE_RELATIVE_PATH_PATTERN = %r{\A[A-Za-z0-9._/-]+\z}
18
+ MANAGED_PATH_PATTERN = /
19
+ \A(?:
20
+ global\.diagnostics\.json |
21
+ [a-z][a-z0-9_]*\.(?:diagnostics\.json|(?:er|class)\.mmd|(?:er|class)\.render_plan\.json)
22
+ )\z
23
+ /x
24
+
25
+ def initialize(project_root:)
26
+ @project_root = Pathname(project_root).expand_path
27
+ @project_root_real = @project_root.realpath
28
+ end
29
+
30
+ def resolve(value)
31
+ text = value.to_s
32
+ reason = lexical_rejection_reason(text)
33
+ return invalid_resolution(reason) if reason
34
+
35
+ relative_path = normalize(text)
36
+ return invalid_resolution('normalizes to project root') if relative_path == '.'
37
+ return invalid_resolution('escapes project root') if relative_path.start_with?('../') || relative_path == '..'
38
+
39
+ absolute_path = project_root.join(relative_path)
40
+ return invalid_resolution('symlink escapes project root') unless inside_project_root?(absolute_path)
41
+
42
+ Resolution.new(path: absolute_path, relative_path: relative_path)
43
+ end
44
+
45
+ def publish!(resolution, selected_domain_ids:)
46
+ raise ArgumentError, 'output directory is unresolved' unless resolution&.valid? && resolution.path
47
+
48
+ output_path = resolution.path
49
+ raise ArgumentError, 'output path escapes project root' unless inside_project_root?(output_path)
50
+
51
+ output_path.mkpath
52
+ ensure_inside_project_root!(output_path)
53
+ plan = yield
54
+ ensure_managed_plan!(plan, selected_domain_ids)
55
+ write_plan(output_path, plan, selected_domain_ids)
56
+ plan.keys.sort
57
+ end
58
+
59
+ private
60
+
61
+ attr_reader :project_root, :project_root_real
62
+
63
+ def lexical_rejection_reason(text)
64
+ return 'is empty' if text.empty?
65
+ return 'contains NUL' if text.include?("\u0000")
66
+ return 'contains backslash' if text.include?('\\')
67
+ return 'uses drive or UNC form' if text.match?(DRIVE_OR_UNC_PATTERN)
68
+ return 'is absolute' if Pathname(text).absolute?
69
+
70
+ 'contains unsupported characters' unless text.match?(SAFE_RELATIVE_PATH_PATTERN)
71
+ end
72
+
73
+ def normalize(text)
74
+ Pathname(text).cleanpath.to_s
75
+ end
76
+
77
+ def inside_project_root?(absolute_path)
78
+ existing = closest_existing_path(absolute_path)
79
+ relative = existing.realpath.relative_path_from(project_root_real).to_s
80
+ relative != '..' && !relative.start_with?('../')
81
+ rescue Errno::ENOENT
82
+ false
83
+ end
84
+
85
+ def closest_existing_path(path)
86
+ current = Pathname(path)
87
+ current = current.parent until current.exist?
88
+ current
89
+ end
90
+
91
+ def write_plan(output_path, plan, selected_domain_ids)
92
+ ensure_safe_replacement_set!(output_path, plan, selected_domain_ids)
93
+ Dir.mktmpdir('.rails-mmd-', output_path.to_s) do |staging_directory|
94
+ staging_path = Pathname(staging_directory)
95
+ plan.each { |relative_path, content| staging_path.join(relative_path).write(content) }
96
+ ensure_inside_project_root!(output_path)
97
+ ensure_safe_replacement_set!(output_path, plan, selected_domain_ids)
98
+ replace_plan_files(output_path, staging_path, plan, selected_domain_ids)
99
+ end
100
+ end
101
+
102
+ def replace_plan_files(output_path, tmp_path, plan, selected_domain_ids)
103
+ backup_path = Pathname(Dir.mktmpdir('.rails-mmd-backup-', output_path.to_s))
104
+ published_paths = []
105
+ backup_existing_files(output_path, backup_path, selected_domain_ids)
106
+ move_plan_files(output_path, tmp_path, plan, published_paths)
107
+ replacement_complete = true
108
+ rescue StandardError
109
+ rollback_replacement(output_path, backup_path, published_paths) if backup_path
110
+ raise
111
+ ensure
112
+ FileUtils.remove_entry(backup_path) if backup_path && (replacement_complete || backup_path.children.empty?)
113
+ end
114
+
115
+ def backup_existing_files(output_path, backup_path, selected_domain_ids)
116
+ scoped_managed_paths(output_path, selected_domain_ids).each do |path|
117
+ ensure_safe_target!(output_path, path.basename.to_s)
118
+ FileUtils.mv(path.to_s, backup_path.join(path.basename).to_s)
119
+ end
120
+ end
121
+
122
+ def move_plan_files(output_path, tmp_path, plan, published_paths)
123
+ plan.each_key do |relative_path|
124
+ replace_file(output_path, tmp_path, relative_path)
125
+ published_paths << relative_path
126
+ end
127
+ end
128
+
129
+ def rollback_replacement(output_path, backup_path, published_paths)
130
+ published_paths.each do |relative_path|
131
+ target = output_path.join(relative_path)
132
+ target.delete if target.file? && !target.symlink?
133
+ end
134
+ backup_path.children.each do |path|
135
+ ensure_safe_target!(output_path, path.basename.to_s)
136
+ FileUtils.mv(path.to_s, output_path.join(path.basename).to_s)
137
+ end
138
+ end
139
+
140
+ def ensure_managed_plan!(plan, selected_domain_ids)
141
+ plan.each_key do |relative_path|
142
+ unless relative_path.is_a?(String) && relative_path.match?(MANAGED_PATH_PATTERN) &&
143
+ scoped_managed_path?(relative_path, selected_domain_ids)
144
+ raise ArgumentError, 'output plan path is unmanaged'
145
+ end
146
+ end
147
+ end
148
+
149
+ def ensure_safe_replacement_set!(output_path, plan, selected_domain_ids)
150
+ scoped_managed_paths(output_path, selected_domain_ids).each do |path|
151
+ ensure_safe_target!(output_path, path.basename.to_s)
152
+ end
153
+ plan.each_key { |relative_path| ensure_safe_target!(output_path, relative_path) }
154
+ end
155
+
156
+ def scoped_managed_paths(output_path, selected_domain_ids)
157
+ output_path.children.select do |path|
158
+ path.basename.to_s.match?(MANAGED_PATH_PATTERN) &&
159
+ scoped_managed_path?(path.basename.to_s, selected_domain_ids)
160
+ end
161
+ end
162
+
163
+ def scoped_managed_path?(name, selected_domain_ids)
164
+ return true if name == 'global.diagnostics.json'
165
+
166
+ selected_domain_ids.any? { |domain_id| name.start_with?("#{domain_id}.") }
167
+ end
168
+
169
+ def replace_file(output_path, tmp_path, relative_path)
170
+ ensure_inside_project_root!(output_path)
171
+ ensure_safe_target!(output_path, relative_path)
172
+ FileUtils.mv(tmp_path.join(relative_path).to_s, output_path.join(relative_path).to_s)
173
+ end
174
+
175
+ def ensure_safe_target!(output_path, relative_path)
176
+ target = output_path.join(relative_path)
177
+ raise ArgumentError, 'output target is symlink' if target.symlink?
178
+ raise ArgumentError, 'output target is not a regular file' if target.exist? && !target.file?
179
+
180
+ ensure_inside_project_root!(target.parent)
181
+ end
182
+
183
+ def ensure_inside_project_root!(path)
184
+ relative = path.realpath.relative_path_from(project_root_real).to_s
185
+ raise ArgumentError, 'output path escapes project root' if relative == '..' || relative.start_with?('../')
186
+ end
187
+
188
+ def invalid_resolution(reason)
189
+ Resolution.new(reason: reason)
190
+ end
191
+ end
192
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'rails_mmd/diagnostic_factory'
5
+ require 'rails_mmd/output_directory'
6
+ require 'rails_mmd/schema_validator'
7
+
8
+ module RailsMmd
9
+ # Atomically publishes diagnostics, render plans, and Mermaid artifacts.
10
+ # rubocop:disable-next Metrics/ClassLength
11
+ class Publisher
12
+ Result = Struct.new(:success, :diagnostics, :written_paths, :stderr, keyword_init: true) do
13
+ def success?
14
+ success
15
+ end
16
+ end
17
+
18
+ PRE_OUTPUT_FATAL_CODES = %w[
19
+ CONFIG_NOT_FOUND
20
+ CONFIG_SCHEMA_INVALID
21
+ CONFIG_DOMAIN_NOT_FOUND
22
+ OUTPUT_DIRECTORY_INVALID
23
+ ].freeze
24
+ ARTIFACT_KINDS = %w[er class].freeze
25
+ def initialize(project_root:, schema_validator: SchemaValidator.new, diagnostics_factory: DiagnosticFactory.new)
26
+ @output_directory = OutputDirectory.new(project_root: project_root)
27
+ @schema_validator = schema_validator
28
+ @diagnostics_factory = diagnostics_factory
29
+ end
30
+
31
+ def pre_output_stderr(diagnostics)
32
+ document = schema_validator.validate_pre_output!(diagnostics)
33
+ "#{JSON.pretty_generate(document)}\n"
34
+ end
35
+
36
+ def publish(output_dir:, selected_domain_ids:, diagnostics:, artifacts:)
37
+ return pre_output_fatal_result(diagnostics) if pre_output_fatal?(diagnostics)
38
+
39
+ output_resolution = output_directory.resolve(output_dir)
40
+ return invalid_output_result(output_resolution.reason) unless output_resolution.valid?
41
+
42
+ publish_inside_output(output_resolution, selected_domain_ids, diagnostics, artifacts)
43
+ rescue StandardError => e
44
+ Result.new(success: false, diagnostics: [output_write_failed(e)], written_paths: [], stderr: nil)
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :diagnostics_factory, :output_directory, :schema_validator
50
+
51
+ def publish_inside_output(output_resolution, selected_domain_ids, diagnostics, artifacts)
52
+ written_paths = output_directory.publish!(output_resolution, selected_domain_ids: selected_domain_ids) do
53
+ diagnostics = scoped_diagnostics(diagnostics, selected_domain_ids)
54
+ artifacts = scoped_artifacts(artifacts, selected_domain_ids)
55
+ diagnostic_documents = validate_payloads!(diagnostics, artifacts)
56
+ blocked_domains = blocked_domains(selected_domain_ids, diagnostics)
57
+ publish_plan(selected_domain_ids, diagnostic_documents, artifacts, blocked_domains)
58
+ end
59
+ Result.new(success: true, diagnostics: diagnostics, written_paths: written_paths, stderr: nil)
60
+ end
61
+
62
+ def validate_payloads!(diagnostics, artifacts)
63
+ schema_validator.validate_publication!(
64
+ diagnostics: diagnostics,
65
+ artifacts: artifacts
66
+ )
67
+ end
68
+
69
+ def scoped_diagnostics(diagnostics, selected_domain_ids)
70
+ diagnostics.select do |diagnostic|
71
+ diagnostic.fetch('scope') == 'invocation' || selected_domain_ids.include?(diagnostic_domain_id(diagnostic))
72
+ end
73
+ end
74
+
75
+ def scoped_artifacts(artifacts, selected_domain_ids)
76
+ artifacts.slice(*selected_domain_ids)
77
+ end
78
+
79
+ def publish_plan(selected_domain_ids, diagnostic_documents, artifacts, blocked_domains)
80
+ plan = diagnostics_plan(diagnostic_documents)
81
+ selected_domain_ids.each do |domain_id|
82
+ next if blocked_domains.include?(domain_id)
83
+
84
+ plan.merge!(domain_publish_plan(domain_id, artifacts.fetch(domain_id, {})))
85
+ end
86
+ plan
87
+ end
88
+
89
+ def domain_publish_plan(domain_id, artifacts)
90
+ ARTIFACT_KINDS.each_with_object({}) do |kind, plan|
91
+ artifact = artifacts[kind]
92
+ next unless artifact
93
+
94
+ plan["#{domain_id}.#{kind}.mmd"] = artifact.fetch(:mermaid)
95
+ plan["#{domain_id}.#{kind}.render_plan.json"] = json_document(artifact.fetch(:render_plan))
96
+ end
97
+ end
98
+
99
+ def diagnostics_plan(diagnostic_documents)
100
+ diagnostic_documents.each_with_object({}) do |document, plan|
101
+ name = if document.fetch('scope') == 'global'
102
+ 'global.diagnostics.json'
103
+ else
104
+ "#{document.fetch('domain_id')}.diagnostics.json"
105
+ end
106
+ plan[name] = json_document(document)
107
+ end
108
+ end
109
+
110
+ def diagnostic_domain_id(diagnostic)
111
+ diagnostic.fetch('metadata', {}).fetch('domain_id')
112
+ end
113
+
114
+ def blocked_domains(selected_domain_ids, diagnostics)
115
+ return selected_domain_ids if invocation_blocking?(diagnostics)
116
+
117
+ diagnostics.each_with_object([]) do |diagnostic, blocked|
118
+ next unless blocking?(diagnostic)
119
+ next if diagnostic.fetch('scope') == 'invocation'
120
+
121
+ blocked << diagnostic_domain_id(diagnostic)
122
+ end.uniq
123
+ end
124
+
125
+ def invocation_blocking?(diagnostics)
126
+ diagnostics.any? { |diagnostic| diagnostic.fetch('scope') == 'invocation' && blocking?(diagnostic) }
127
+ end
128
+
129
+ def blocking?(diagnostic)
130
+ %w[fatal error].include?(diagnostic.fetch('severity'))
131
+ end
132
+
133
+ def json_document(payload)
134
+ "#{JSON.pretty_generate(payload)}\n"
135
+ end
136
+
137
+ def invalid_output_result(reason)
138
+ diagnostic = diagnostics_factory.build(
139
+ code: 'OUTPUT_DIRECTORY_INVALID',
140
+ message: 'output directory invalid',
141
+ metadata: { field_path: '$.output.directory', reason: reason.to_s }
142
+ )
143
+ Result.new(success: false, diagnostics: [diagnostic], written_paths: [], stderr: pre_output_stderr([diagnostic]))
144
+ end
145
+
146
+ def pre_output_fatal?(diagnostics)
147
+ diagnostics.any? { |diagnostic| PRE_OUTPUT_FATAL_CODES.include?(diagnostic.fetch('code')) }
148
+ end
149
+
150
+ def pre_output_fatal_result(diagnostics)
151
+ Result.new(success: false, diagnostics: diagnostics, written_paths: [], stderr: pre_output_stderr(diagnostics))
152
+ end
153
+
154
+ def output_write_failed(exception)
155
+ diagnostics_factory.build(
156
+ code: 'OUTPUT_WRITE_FAILED',
157
+ message: 'output write failed',
158
+ metadata: { operation: 'write', path: 'output' },
159
+ remediation: {
160
+ summary: exception.class.name,
161
+ steps: ['Check the output directory for retained .rails-mmd-backup-* directories before retrying.']
162
+ }
163
+ )
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_mmd/diagnostic_factory'
4
+
5
+ module RailsMmd
6
+ # Boots a host Rails app and eager-loads it through explicit, testable paths.
7
+ # rubocop:disable-next Metrics/MethodLength
8
+ class RailsLoader
9
+ Result = Struct.new(:application, :diagnostics, :exit_code, keyword_init: true) do
10
+ def success?
11
+ diagnostics.empty?
12
+ end
13
+ end
14
+
15
+ EXIT_CONTRACT_ERROR = 2
16
+
17
+ def initialize(project_root:, kernel: Kernel, rails_provider: -> { Object.const_get(:Rails) },
18
+ diagnostics: DiagnosticFactory.new, bundler: (Bundler if defined?(Bundler)))
19
+ @project_root = Pathname(project_root).expand_path
20
+ @kernel = kernel
21
+ @rails_provider = rails_provider
22
+ @diagnostic_factory = diagnostics
23
+ @bundler = bundler
24
+ end
25
+
26
+ def boot
27
+ validate_bundle_context!
28
+ kernel.load(environment_path.to_s)
29
+ application = rails_provider.call.application
30
+ eager_load(application)
31
+ Result.new(application: application, diagnostics: [], exit_code: 0)
32
+ rescue EagerLoadError => e
33
+ failure('RAILS_EAGER_LOAD_FAILED', e.cause || e)
34
+ rescue LoadError, SyntaxError, StandardError => e
35
+ failure('RAILS_LOAD_FAILED', e)
36
+ end
37
+
38
+ private
39
+
40
+ class EagerLoadError < StandardError
41
+ end
42
+
43
+ class BundleContextError < StandardError
44
+ end
45
+
46
+ attr_reader :bundler, :diagnostic_factory, :kernel, :project_root, :rails_provider
47
+
48
+ def environment_path
49
+ project_root.join('config/environment.rb')
50
+ end
51
+
52
+ def validate_bundle_context!
53
+ app_gemfile = project_root.join('Gemfile')
54
+ return unless app_gemfile.file?
55
+
56
+ current_gemfile = current_bundle_gemfile
57
+ return if current_gemfile &&
58
+ File.basename(current_gemfile) == app_gemfile.basename.to_s &&
59
+ File.identical?(File.dirname(current_gemfile), project_root)
60
+
61
+ raise BundleContextError,
62
+ 'rails-mmd must be run inside the target Rails application bundle'
63
+ end
64
+
65
+ def current_bundle_gemfile
66
+ return unless bundler.respond_to?(:default_gemfile)
67
+
68
+ bundler.default_gemfile
69
+ rescue LoadError, StandardError
70
+ nil
71
+ end
72
+
73
+ def eager_load(application)
74
+ raise EagerLoadError, 'Rails application does not expose eager_load!' unless application.respond_to?(:eager_load!)
75
+
76
+ application.eager_load! if application.respond_to?(:eager_load!)
77
+ rescue LoadError, SyntaxError, StandardError => e
78
+ raise EagerLoadError, e.message
79
+ end
80
+
81
+ def failure(code, exception)
82
+ Result.new(
83
+ application: nil,
84
+ diagnostics: [
85
+ diagnostic_factory.build(
86
+ code: code,
87
+ message: exception.message,
88
+ subject_id: 'rails',
89
+ metadata: diagnostic_factory.exception_metadata(exception)
90
+ )
91
+ ],
92
+ exit_code: EXIT_CONTRACT_ERROR
93
+ )
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pathname'
4
+ require 'uri'
5
+
6
+ module RailsMmd
7
+ # Sanitizes free-form text before it can leave the process.
8
+ # rubocop:disable-next Metrics/ClassLength
9
+ class Redactor
10
+ SECRET_KEY_PATTERN = /
11
+ (?:password|passwd|secret|api[_-]?key|credential|database_url|(?:access|auth|refresh)[_-]?token|\btoken\b)
12
+ /ix
13
+ # Free text has no reliable value delimiter, so keep only the context before an assignment.
14
+ SECRET_ASSIGNMENT_PATTERN = /
15
+ ["']?[A-Za-z0-9_-]*#{SECRET_KEY_PATTERN}[A-Za-z0-9_-]*["']?\s*(?:=>|=|:)[\s\S]*
16
+ /ix
17
+ URL_CREDENTIAL_PATTERN = %r{([a-z][a-z0-9+.-]*://)[^/\s]+@}i
18
+ DRIVE_OR_UNC_PATTERN = %r{\b(?:[A-Za-z]:[\\/]|\\\\[^\\/\s]+[\\/][^\\/\s]+)}
19
+ MERMAID_SECRET_KEY = /
20
+ (?:
21
+ password|passwd|secret|credential|token|pid|
22
+ api\s*[_-]?\s*key|
23
+ database\s*[_-]?\s*url|
24
+ (?:access|auth|refresh)\s*[_-]?\s*token|
25
+ generated\s*[_-]?\s*at|
26
+ process\s*[_-]?\s*id|
27
+ random\s*[_-]?\s*seed|
28
+ raw\s*[_-]?\s*exception\s*[_-]?\s*backtrace
29
+ )
30
+ /ix
31
+ MERMAID_FORBIDDEN_ASSIGNMENT = /
32
+ ["']?[A-Za-z0-9_-]*#{MERMAID_SECRET_KEY}[A-Za-z0-9_-]*["']?\s*(?:=>|:|=|\s+)[\s\S]*
33
+ /ix
34
+ REDACTED_KEY_ASSIGNMENT = /
35
+ ["']?[A-Za-z0-9_-]*\[REDACTED_KEY\][A-Za-z0-9_-]*["']?\s*(?:=>|:|=|\s+)[\s\S]*
36
+ /x
37
+ URL_PATTERN = %r{\b[a-z][a-z0-9+.-]*://[^\s]+}i
38
+ URL_PLACEHOLDER_PREFIX = "\uE000RAILSMMDURL"
39
+ REDACTED_URL = '[REDACTED_URL]'
40
+ MERMAID_CONTROL_TEXT = /[\r\n\t[:cntrl:]]+/
41
+ CONTROL_SPLIT_ABSOLUTE_PATH =
42
+ /(?<![A-Za-z0-9_])\/[^[:cntrl:]\s)\]}>;,:!?]+(?:[\r\n\t[:cntrl:]][^[:cntrl:]\s)\]}>;,:!?]*)*/
43
+ STRUCTURED_PATTERNS = {
44
+ ruby_constant: /\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/,
45
+ ruby_constant_label: /\A[A-Z][A-Za-z0-9_]*\z/,
46
+ snake_identifier: /\A[a-z][a-z0-9_]*\z/
47
+ }.freeze
48
+ STRUCTURED_FALLBACK = 'X'
49
+
50
+ class << self
51
+ def sanitize_mermaid_free_text(value, sanitizer:)
52
+ urls = []
53
+ text = protect_urls(value.to_s.delete("\u0000"), urls)
54
+ text = text.gsub(CONTROL_SPLIT_ABSOLUTE_PATH, '[REDACTED_PATH]')
55
+ text = text.gsub(MERMAID_CONTROL_TEXT, ' ')
56
+ text = text.gsub(MERMAID_FORBIDDEN_ASSIGNMENT, '[REDACTED]')
57
+ text = sanitizer.sanitize(text).gsub(REDACTED_KEY_ASSIGNMENT, '[REDACTED]')
58
+ restore_urls(text, urls).gsub(/\s+/, ' ').strip
59
+ end
60
+
61
+ def sanitize_mermaid_structured(value, grammar:)
62
+ text = value.to_s.delete("\u0000").gsub(MERMAID_CONTROL_TEXT, ' ').gsub(/\s+/, ' ').strip
63
+ return STRUCTURED_FALLBACK unless STRUCTURED_PATTERNS.fetch(grammar).match?(text)
64
+
65
+ text
66
+ end
67
+
68
+ private
69
+
70
+ def protect_urls(text, urls)
71
+ text.gsub(URL_PATTERN) do |_url|
72
+ urls << REDACTED_URL
73
+ "#{URL_PLACEHOLDER_PREFIX}#{urls.length - 1}"
74
+ end
75
+ end
76
+
77
+ def restore_urls(text, urls)
78
+ urls.each_with_index.reduce(text) do |output, (url, index)|
79
+ output.gsub("#{URL_PLACEHOLDER_PREFIX}#{index}", url)
80
+ end
81
+ end
82
+ end
83
+
84
+ def initialize(project_root: Dir.pwd, env: ENV.to_h)
85
+ @project_root = Pathname(project_root).expand_path
86
+ @env_values = env.values.compact.map(&:to_s).select { |value| value.length >= 4 }.uniq
87
+ end
88
+
89
+ def sanitize(value)
90
+ text = value.to_s.dup
91
+ text = text.delete("\u0000")
92
+ text = text.gsub(URL_CREDENTIAL_PATTERN, '\\1[REDACTED]@')
93
+ text = text.gsub(SECRET_ASSIGNMENT_PATTERN, '[REDACTED]')
94
+ text = text.gsub(DRIVE_OR_UNC_PATTERN, '[REDACTED_PATH]')
95
+ text = redact_absolute_paths(text)
96
+ text = redact_env_values(text)
97
+ text.gsub(SECRET_KEY_PATTERN, '[REDACTED_KEY]')
98
+ end
99
+
100
+ def sanitize_object(value)
101
+ case value
102
+ when String
103
+ sanitize(value)
104
+ when Array
105
+ value.map { |item| sanitize_object(item) }
106
+ when Hash
107
+ sanitize_hash(value)
108
+ else
109
+ value
110
+ end
111
+ end
112
+
113
+ private
114
+
115
+ attr_reader :project_root, :env_values
116
+
117
+ def sanitize_hash(value)
118
+ value.to_h do |key, item|
119
+ sanitized_value = SECRET_KEY_PATTERN.match?(key.to_s) ? '[REDACTED]' : sanitize_object(item)
120
+ [sanitize(key), sanitized_value]
121
+ end
122
+ end
123
+
124
+ def redact_env_values(text)
125
+ env_values.reduce(text) { |output, value| output.gsub(value, '[REDACTED]') }
126
+ end
127
+
128
+ def redact_absolute_paths(text)
129
+ text.gsub(%r{(?:/[\w.-]+){2,}}) do |path|
130
+ display_path(path)
131
+ end
132
+ end
133
+
134
+ def display_path(path)
135
+ pathname = Pathname(path)
136
+ expanded = pathname.expand_path
137
+ relative = expanded.relative_path_from(project_root)
138
+ return relative.to_s unless relative.to_s.start_with?('..')
139
+
140
+ '[REDACTED_PATH]'
141
+ end
142
+ end
143
+ end