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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'json_schemer'
5
+ require 'rails_mmd/canonical_json'
6
+ require 'rails_mmd/ordering'
7
+
8
+ module RailsMmd
9
+ # Validates payloads against repository JSON Schemas.
10
+ class SchemaValidator
11
+ class InvalidPayload < ArgumentError; end
12
+
13
+ SCHEMA_FILES = {
14
+ config: 'config.schema.json',
15
+ diagnostics: 'diagnostics.schema.json',
16
+ ir: 'ir.schema.json',
17
+ render_plan: 'render_plan.schema.json'
18
+ }.freeze
19
+
20
+ def initialize(schema_root: Pathname(__dir__).join('../../schemas').expand_path)
21
+ @schema_root = Pathname(schema_root)
22
+ @schemas = {}
23
+ end
24
+
25
+ def valid?(schema_name, payload) = errors(schema_name, payload).empty?
26
+
27
+ def errors(schema_name, payload)
28
+ schema(schema_name).validate(payload).to_a + attribute_identity_errors(schema_name, payload)
29
+ end
30
+
31
+ def validate!(schema_name, payload)
32
+ return payload if valid?(schema_name, payload)
33
+
34
+ raise InvalidPayload, "#{schema_name.to_s.tr('_', ' ')} schema invalid"
35
+ end
36
+
37
+ def validate_render_plan!(render_plan) = validate!(:render_plan, render_plan)
38
+
39
+ def validate_pre_output!(diagnostics)
40
+ validate!(:diagnostics, diagnostic_document('global', nil, diagnostics))
41
+ end
42
+
43
+ def validate_publication!(diagnostics:, artifacts:)
44
+ diagnostic_documents = diagnostic_documents(diagnostics)
45
+ diagnostic_documents.each { |document| validate!(:diagnostics, document) }
46
+ each_render_plan(artifacts) { |render_plan| validate_render_plan!(render_plan) }
47
+ validate_diagnostic_refs!(diagnostic_documents, artifacts)
48
+ diagnostic_documents
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :schema_root, :schemas
54
+
55
+ def schema(schema_name)
56
+ key = schema_name.to_sym
57
+ schemas[key] ||= JSONSchemer.schema(JSON.parse(schema_root.join(SCHEMA_FILES.fetch(key)).read))
58
+ end
59
+
60
+ def attribute_identity_errors(schema_name, payload)
61
+ return [] unless %i[ir render_plan].include?(schema_name.to_sym)
62
+ return [] unless payload.is_a?(Hash) && payload['entities'].is_a?(Array)
63
+
64
+ seen = {}
65
+ payload['entities'].each_with_index.flat_map { |entity, index| entity_attribute_errors(entity, index, seen) }
66
+ end
67
+
68
+ def entity_attribute_errors(entity, entity_index, seen)
69
+ return [] unless entity.is_a?(Hash) && entity['attributes'].is_a?(Array)
70
+
71
+ entity['attributes'].each_with_index.filter_map do |attribute, index|
72
+ duplicate_attribute_error(attribute, "/entities/#{entity_index}/attributes/#{index}/attribute_id", seen)
73
+ end
74
+ end
75
+
76
+ def duplicate_attribute_error(attribute, pointer, seen)
77
+ return unless attribute.is_a?(Hash) && attribute['attribute_id'].is_a?(String)
78
+
79
+ attribute_id = attribute.fetch('attribute_id')
80
+ duplicate = seen.key?(attribute_id)
81
+ seen[attribute_id] = true
82
+ return unless duplicate
83
+
84
+ { 'data_pointer' => pointer, 'type' => 'unique_attribute_identity' }
85
+ end
86
+
87
+ def each_render_plan(artifacts)
88
+ artifacts.each_value do |domain_artifacts|
89
+ domain_artifacts.each_value { |artifact| yield artifact.fetch(:render_plan) }
90
+ end
91
+ end
92
+
93
+ def validate_diagnostic_refs!(diagnostic_documents, artifacts)
94
+ diagnostic_ids = diagnostic_ids_by_domain(diagnostic_documents)
95
+ artifacts.each do |domain_id, domain_artifacts|
96
+ allowed_ids = diagnostic_ids.fetch(nil, []) + diagnostic_ids.fetch(domain_id, [])
97
+ each_render_plan(domain_id => domain_artifacts) do |render_plan|
98
+ unresolved = render_plan.fetch('diagnostic_ids') - allowed_ids
99
+ raise InvalidPayload, 'render plan diagnostic_ids unresolved' unless unresolved.empty?
100
+ end
101
+ end
102
+ end
103
+
104
+ def diagnostic_ids_by_domain(diagnostic_documents)
105
+ diagnostic_documents.each_with_object({}) do |document, ids|
106
+ domain_id = document.fetch('scope') == 'global' ? nil : document.fetch('domain_id')
107
+ ids[domain_id] ||= []
108
+ ids[domain_id].concat(document.fetch('diagnostics').map { |diagnostic| diagnostic.fetch('diagnostic_id') })
109
+ end
110
+ end
111
+
112
+ def diagnostic_documents(diagnostics)
113
+ groups = diagnostics.group_by do |diagnostic|
114
+ diagnostic.fetch('scope') == 'invocation' ? ['global', nil] : ['domain', diagnostic_domain_id(diagnostic)]
115
+ end
116
+ groups.map do |(scope, domain_id), items|
117
+ diagnostic_document(scope, domain_id, items)
118
+ end
119
+ end
120
+
121
+ def diagnostic_document(scope, domain_id, diagnostics)
122
+ payload = {
123
+ 'schema_version' => 1,
124
+ 'scope' => scope,
125
+ 'domain_id' => domain_id,
126
+ 'diagnostics' => Ordering.sort_diagnostics(diagnostics),
127
+ 'digest_sha256' => nil
128
+ }
129
+ payload.merge('digest_sha256' => CanonicalJson.digest_sha256(payload))
130
+ end
131
+
132
+ def diagnostic_domain_id(diagnostic)
133
+ diagnostic.fetch('metadata', {}).fetch('domain_id')
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMmd
4
+ # Normalizes diagnostic subject identifiers without treating them as free text.
5
+ module SubjectIds
6
+ PATTERN = /\A[A-Za-z0-9_:.]+\z/
7
+
8
+ module_function
9
+
10
+ def normalize(value, code:)
11
+ subject_id = value&.to_s
12
+ return subject_id if subject_id.nil? || subject_id.match?(PATTERN)
13
+
14
+ raise ArgumentError, "invalid diagnostic subject_id for #{code}"
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMmd
4
+ # Shared command builders for repository Rake tooling.
5
+ module ToolingTasks
6
+ BUNDLE_AUDIT_COMMAND = %w[bundle exec bundler-audit check --no-update].freeze
7
+ BUNDLE_AUDIT_UPDATE_COMMAND = %w[bundle exec bundler-audit update].freeze
8
+
9
+ module_function
10
+
11
+ def git_ref?(ref)
12
+ system('git', 'rev-parse', '--verify', '--quiet', "#{ref}^{commit}", out: File::NULL)
13
+ end
14
+
15
+ def undercover_compare_ref(default_ref)
16
+ env_ref = ENV.fetch('UNDERCOVER_COMPARE', nil)
17
+ unless env_ref.nil?
18
+ abort "UNDERCOVER_COMPARE does not resolve to a commit: #{env_ref.inspect}" unless git_ref?(env_ref)
19
+
20
+ return env_ref
21
+ end
22
+
23
+ [default_ref, 'origin/main', 'main', 'HEAD^'].find { |ref| git_ref?(ref) }
24
+ end
25
+
26
+ def undercover_command(config)
27
+ compare_ref = undercover_compare_ref(config.fetch('compare'))
28
+ abort 'No usable Undercover compare ref found' unless compare_ref
29
+
30
+ [
31
+ { 'SIMPLECOV_NO_DEFAULTS' => 'true' },
32
+ 'bundle', 'exec', 'undercover',
33
+ *undercover_options(config, compare_ref)
34
+ ]
35
+ end
36
+
37
+ def undercover_options(config, compare_ref)
38
+ [
39
+ '--simplecov', config.fetch('simplecov'),
40
+ '--compare', compare_ref,
41
+ '--include-files', config.fetch('include_files'),
42
+ '--exclude-files', config.fetch('exclude_files'),
43
+ '--max-warnings', config.fetch('max_warnings').to_s
44
+ ]
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module RailsMmd
6
+ # Owns pre-commit path classification, ordering, and command selection.
7
+ class VerificationRunner
8
+ class CommandFailed < StandardError; end
9
+
10
+ RUBY_PATH = %r{
11
+ \A(?:
12
+ [^/]+\.(?:rb|rake)|Rakefile|exe/rails-mmd|bin/.+|lib/.+\.rb|
13
+ spec/(?:.+/)?[^/]+\.rb|features/(?:.+/)?[^/]+\.rb
14
+ )\z
15
+ }x
16
+ DIRECT_SPEC_PATH = %r{\Aspec/.+_spec\.rb\z}
17
+ FULL_SPEC_SUITE_CONFIG_PATHS = %w[.rspec .simplecov .undercover.yml].freeze
18
+ DEPENDENCY_PATH = %r{\A(?:Gemfile(?:\.lock)?|[^/]+\.gemspec)\z}
19
+ CONTRACT_PATH = %r{\A(?:schemas/.+\.json|fixtures/.+\.(?:json|mmd)|AGENTS\.md|README\.md|docs/.+\.md)\z}
20
+
21
+ # The process seam has a production adapter and recording adapters in specs.
22
+ class ProcessAdapter
23
+ def run!(*command)
24
+ return if system(*command)
25
+
26
+ raise CommandFailed, "verification command failed: #{command.join(' ')}"
27
+ end
28
+
29
+ def capture3(*command)
30
+ Open3.capture3(*command)
31
+ end
32
+
33
+ def success?(*command)
34
+ system(*command, out: File::NULL, err: File::NULL)
35
+ end
36
+ end
37
+
38
+ def initialize(process: ProcessAdapter.new, output: $stdout, error: $stderr)
39
+ @process = process
40
+ @output = output
41
+ @error = error
42
+ end
43
+
44
+ def run!(paths)
45
+ route(normalize_paths(paths))
46
+ rescue CommandFailed => e
47
+ error.puts(e.message)
48
+ raise SystemExit, 1
49
+ end
50
+
51
+ private
52
+
53
+ attr_reader :error, :output, :process
54
+
55
+ def route(paths)
56
+ run_ruby_checks(paths.grep(RUBY_PATH))
57
+ run_specs(paths)
58
+ run_dependency_checks(paths)
59
+ run_ruby_version_smoke(paths)
60
+ run_rubocop_config(paths)
61
+ run_contracts(paths)
62
+ report_p0_contract(paths)
63
+ end
64
+
65
+ def normalize_paths(paths)
66
+ paths.flatten.compact.map(&:to_s).reject(&:empty?).uniq
67
+ end
68
+
69
+ def run_ruby_checks(paths)
70
+ return if paths.empty?
71
+
72
+ ensure_no_mixed_changes!(paths)
73
+ process.run!('bundle', 'exec', 'rubocop', '--autocorrect-all', '--force-exclusion', '--', *paths)
74
+ ensure_no_post_repair_diff!
75
+ process.run!('bundle', 'exec', 'rubocop', '--force-exclusion', '--', *paths)
76
+ end
77
+
78
+ def ensure_no_mixed_changes!(paths)
79
+ mixed_paths = unstaged_changed_paths(paths)
80
+ return if mixed_paths.empty?
81
+
82
+ report_mixed_paths(mixed_paths)
83
+ raise SystemExit, 1
84
+ end
85
+
86
+ def unstaged_changed_paths(paths)
87
+ stdout, stderr, status = process.capture3('git', 'diff', '--name-only', '--', *paths)
88
+ raise CommandFailed, stderr unless status.success?
89
+
90
+ stdout.lines.map(&:chomp).reject(&:empty?)
91
+ end
92
+
93
+ def report_mixed_paths(mixed_paths)
94
+ error.puts('pre-commit blocked: these staged files also have unstaged changes:')
95
+ mixed_paths.each { |path| error.puts(" #{path}") }
96
+ error.puts('Stage or stash the unstaged changes before repair commands run.')
97
+ end
98
+
99
+ def ensure_no_post_repair_diff!
100
+ return if process.success?('git', 'diff', '--quiet')
101
+
102
+ error.puts('pre-commit blocked: repair changed tracked files.')
103
+ error.puts('Inspect the deterministic repair diff, then stage the intended changes.')
104
+ raise SystemExit, 1
105
+ end
106
+
107
+ def run_specs(paths)
108
+ if paths.intersect?(FULL_SPEC_SUITE_CONFIG_PATHS)
109
+ process.run!('bundle', 'exec', 'rake', 'coverage')
110
+ elsif (specs = paths.grep(DIRECT_SPEC_PATH)).any?
111
+ process.run!('bundle', 'exec', 'rspec', *specs)
112
+ end
113
+ end
114
+
115
+ def run_dependency_checks(paths)
116
+ return unless paths.any? { |path| path.match?(DEPENDENCY_PATH) }
117
+
118
+ process.run!('bundle', 'check')
119
+ process.run!('bundle', 'exec', 'rake', 'bundle:audit')
120
+ end
121
+
122
+ def run_ruby_version_smoke(paths)
123
+ return unless paths.include?('.ruby-version')
124
+
125
+ process.run!('ruby', '-v')
126
+ process.run!('bundle', 'exec', 'exe/rails-mmd', '--version')
127
+ end
128
+
129
+ def run_rubocop_config(paths)
130
+ process.run!('bundle', 'exec', 'rake', 'rubocop') if paths.include?('.rubocop.yml')
131
+ end
132
+
133
+ def run_contracts(paths)
134
+ process.run!('bundle', 'exec', 'rspec', 'spec/contracts') if paths.any? { |path| path.match?(CONTRACT_PATH) }
135
+ end
136
+
137
+ def report_p0_contract(paths)
138
+ return unless paths.include?('docs/p0-contract.md')
139
+
140
+ output.puts('docs/p0-contract.md changed; no P0 contract verifier exists yet.')
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMmd
4
+ VERSION = '0.1.0'
5
+ end
data/lib/rails_mmd.rb ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'rails_mmd/version'
4
+ require_relative 'rails_mmd/config'
5
+ require_relative 'rails_mmd/constant_resolver'
6
+ require_relative 'rails_mmd/domain_resolver'
7
+ require_relative 'rails_mmd/model_inventory'
8
+ require_relative 'rails_mmd/rails_loader'
9
+ require_relative 'rails_mmd/relationship_builder'
10
+ require_relative 'rails_mmd/ir_builder'
11
+ require_relative 'rails_mmd/render_plan_builder'
12
+ require_relative 'rails_mmd/mermaid_serializer'
13
+ require_relative 'rails_mmd/publisher'
14
+ require_relative 'rails_mmd/generate_command'
15
+ require_relative 'rails_mmd/schema_probe'
@@ -0,0 +1,54 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://audiostakes.example/schemas/rails-mmd/config.schema.json",
4
+ "type": "object",
5
+ "additionalProperties": false,
6
+ "required": ["version", "domains"],
7
+ "properties": {
8
+ "version": { "const": 1 },
9
+ "domains": {
10
+ "type": "object",
11
+ "minProperties": 1,
12
+ "propertyNames": {
13
+ "allOf": [
14
+ { "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$" },
15
+ { "not": { "pattern": "\\s" } }
16
+ ]
17
+ },
18
+ "additionalProperties": { "$ref": "#/$defs/domain" }
19
+ },
20
+ "output": {
21
+ "type": "object",
22
+ "additionalProperties": false,
23
+ "properties": {
24
+ "directory": { "type": "string", "minLength": 1 },
25
+ "format": { "enum": ["er", "class", "both"] },
26
+ "attributes": { "enum": ["none", "keys"] },
27
+ "direction": { "enum": ["TB", "BT", "LR", "RL"] }
28
+ }
29
+ }
30
+ },
31
+ "$defs": {
32
+ "ruby_constant": {
33
+ "type": "string",
34
+ "pattern": "^[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*$"
35
+ },
36
+ "domain": {
37
+ "type": "object",
38
+ "additionalProperties": false,
39
+ "required": ["include_models"],
40
+ "properties": {
41
+ "include_models": {
42
+ "type": "array",
43
+ "minItems": 1,
44
+ "items": { "$ref": "#/$defs/ruby_constant" }
45
+ },
46
+ "exclude_models": {
47
+ "type": "array",
48
+ "items": { "$ref": "#/$defs/ruby_constant" },
49
+ "default": []
50
+ }
51
+ }
52
+ }
53
+ }
54
+ }