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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 2439ad7662a9d94eefa0adc2a8805699f5fb606a5a336d136c99f5d0586f8d7a
4
+ data.tar.gz: 8d87bf76c08dcad2af3d27f05aed3c4edd9067d344f04760e5d1778bc30f295b
5
+ SHA512:
6
+ metadata.gz: eb2d72e44b51ce0ff58748a3d2be4cafba64f1619cb8886871d417343431bcfcdf6f8b20c2bf035f0dec2a515b016c117bab3dbbe496eecbefc9ffca8d0f3652
7
+ data.tar.gz: fbcce4fff00660bf1049ee7e04097ea05a315c4169834b746084302314c0c004c978033e6e3082598074789596a0503dbe321e00442ac7503143958aa3d1abef
data/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0
4
+
5
+ Initial release.
6
+
7
+ - Generate Mermaid ER and class diagrams from explicitly configured Rails model domains.
8
+ - Include primary and foreign key attributes and supported direct, through, polymorphic, and HABTM associations. See the [support matrix](docs/active-record-association-support.md) for limits.
9
+ - Write validated render plans and structured diagnostics alongside diagrams, with deterministic output and atomic file replacement.
10
+ - Verify Rails 7.2 with Ruby 4.0.6, and Rails 8.1 with Ruby 3.3.12 and 4.0.6, using real SQLite fixture applications.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AudioStakes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # rails-mmd
2
+
3
+ ![rails-mmd — Rails models to Mermaid diagrams](docs/assets/branding/rails-mmd-social-preview.jpg)
4
+
5
+ Generate Mermaid ER and class diagrams from the models and database schema of a Rails application. `rails-mmd` runs in the application's bundle, loads its models, and writes local `.mmd` source files. Preview them with a Mermaid viewer or embed their contents in a Markdown `mermaid` code block.
6
+
7
+ ```mermaid
8
+ erDiagram
9
+ direction LR
10
+ USER }o..|| ACCOUNT : account
11
+ USER {
12
+ bigint id PK
13
+ bigint account_id FK
14
+ }
15
+ ACCOUNT {
16
+ bigint id PK
17
+ }
18
+ ```
19
+
20
+ The diagram above is illustrative. The actual output depends on your models, associations, and database metadata.
21
+
22
+ ## Try it in a Rails app
23
+
24
+ You need an existing Rails application with Active Record models and a prepared database. Check the [tested Ruby/Rails combinations](#compatibility-and-limits) below before installing. All commands in this section run from that application's root, not this repository.
25
+
26
+ In the **target Rails application's** `Gemfile`, add the published [RubyGems package](https://rubygems.org/gems/rails-mmd):
27
+
28
+ ```ruby
29
+ gem 'rails-mmd', '~> 0.1.0', require: false
30
+ ```
31
+
32
+ Run `bundle install`. The application's `Gemfile.lock` records the selected release. At the application root, create `rails_mmd.yml` and replace the model names with real models in your app:
33
+
34
+ ```yaml
35
+ version: 1
36
+ domains:
37
+ core:
38
+ include_models:
39
+ - User
40
+ - Post
41
+ output:
42
+ directory: tmp/rails_mmd
43
+ format: both
44
+ attributes: keys
45
+ direction: LR
46
+ ```
47
+
48
+ Then run:
49
+
50
+ ```sh
51
+ RAILS_ENV=development bundle exec rails-mmd generate
52
+ ```
53
+
54
+ Inspect `tmp/rails_mmd/core.er.mmd` and `tmp/rails_mmd/core.class.mmd`. A successful run can be silent; these generated files are the result. To view the diagrams, follow [Viewing a generated diagram](docs/usage.md#viewing-a-generated-diagram). The selected environment must be able to boot and read its database schema. The command eager-loads the Rails application; run it only with an application and configuration you trust.
55
+
56
+ The configuration selects models explicitly for each domain. Relationships are drawn between supported models selected in the same domain. To see all options, output files, diagnostics, and exit codes, read [the usage guide](docs/usage.md).
57
+
58
+ ## Compatibility and limits
59
+
60
+ The gem declares Ruby `>= 3.3, < 4.1`. The local real-app matrix verifies Rails 7.2 on Ruby 4.0.6, and Rails 8.1 on Ruby 3.3.12 and 4.0.6. Other combinations within the Ruby range have not been verified by that matrix. The real-app matrix uses SQLite; PostgreSQL and MySQL have not been verified in a real-app run.
61
+
62
+ Supported associations and current limits are listed in the [Active Record support matrix](docs/active-record-association-support.md). In particular, a domain spanning multiple database connection contexts is unsupported, as are composite keys and several association options. Check diagnostics when a diagram omits a relationship.
63
+
64
+ Generated diagrams and JSON artifacts can reveal model names, table names, columns, relationships, and connection identifiers in diagnostics. Review them before committing or sharing them. Output must stay inside the application's root; for private schemas, add the output directory to the application's `.gitignore` (for this example, `/tmp/rails_mmd/`).
65
+
66
+ See the [security policy](https://github.com/AudioStakes/rails-mmd/blob/main/SECURITY.md) for the Rails boot trust boundary and vulnerability reporting.
67
+
68
+ ## How it works
69
+
70
+ The [generation pipeline](https://github.com/AudioStakes/rails-mmd/blob/main/lib/rails_mmd/generation_pipeline.rb) separates model inventory, domain selection, schema probing, relationship construction, and rendering. It converts Rails metadata into an intermediate representation and validated render plans; Mermaid serialization consumes those plans without querying Rails or the database.
71
+
72
+ Relationship cardinality uses database foreign-key, nullability, and unique-index evidence. Structured diagnostics explain omitted unsupported associations and degraded metadata. The [pipeline specs](https://github.com/AudioStakes/rails-mmd/blob/main/spec/rails_mmd/generation_pipeline_spec.rb) exercise these stages together, and the [publisher specs](https://github.com/AudioStakes/rails-mmd/blob/main/spec/rails_mmd/publisher_spec.rb) cover validation, selected-domain output, and rollback when file replacement fails. The [real-app matrix](https://github.com/AudioStakes/rails-mmd/blob/main/docs/development/ci.md) checks the public command against the declared Ruby/Rails pairs.
73
+
74
+ ## Contributing and development
75
+
76
+ See [CONTRIBUTING.md](https://github.com/AudioStakes/rails-mmd/blob/main/CONTRIBUTING.md) for local setup and checks, the [changelog](https://github.com/AudioStakes/rails-mmd/blob/main/CHANGELOG.md) for released changes, and the [release guide](https://github.com/AudioStakes/rails-mmd/blob/main/docs/development/releasing.md) for the manual publishing process. Implementation contracts are in [docs/p0-contract.md](https://github.com/AudioStakes/rails-mmd/blob/main/docs/p0-contract.md); the [development notes](https://github.com/AudioStakes/rails-mmd/blob/main/docs/development/tooling.md) cover the Rake tasks. The contributor guide and development notes are maintained in the repository, outside the installed gem. Serena is optional local development tooling, not a runtime dependency.
77
+
78
+ The [P0 blocking fixture matrix](https://github.com/AudioStakes/rails-mmd/blob/main/docs/p0-blocking-fixture-matrix.md) records the required verification cases.
79
+
80
+ ## License
81
+
82
+ MIT. See [LICENSE](LICENSE).
data/SECURITY.md ADDED
@@ -0,0 +1,40 @@
1
+ # Security
2
+
3
+ ## Reporting a vulnerability
4
+
5
+ Please do not put credentials, private application code, database contents, or
6
+ exploit details in a public issue or pull request.
7
+
8
+ Use [GitHub private vulnerability reporting](https://github.com/AudioStakes/rails-mmd/security/advisories/new)
9
+ to send a suspected vulnerability to the maintainer privately.
10
+
11
+ If the private form is unavailable, open an
12
+ [issue](https://github.com/AudioStakes/rails-mmd/issues/new) asking the maintainer
13
+ for a private reporting channel. Include only that request, with no sensitive
14
+ details, and wait for a private channel before sharing the report.
15
+
16
+ Once a private channel is established, include the affected commit or version,
17
+ Ruby and Rails versions, expected and actual behavior, and a minimal reproduction
18
+ using synthetic data. There is no guaranteed response time or published security
19
+ backport policy yet.
20
+
21
+ ## Running rails-mmd safely
22
+
23
+ `rails-mmd generate` loads the target application's `config/environment.rb` and
24
+ eager-loads its Ruby code in the current process. Initializers and application
25
+ code run with your operating-system permissions, environment, and database
26
+ credentials. Use only applications and configuration you trust. Prefer a local
27
+ development environment with a disposable database; application startup can have
28
+ side effects even though rails-mmd inspects schema metadata rather than records.
29
+
30
+ The tool writes artifacts under the application directory and does not upload
31
+ them. Application startup code may still contact services. The YAML configuration
32
+ uses safe loading, and output paths are checked against the application root;
33
+ these checks do not sandbox application code.
34
+
35
+ Generated diagrams and JSON artifacts contain model, table, column, and
36
+ relationship names. Treat them as application documentation that may be
37
+ confidential. Review artifacts and console output before sharing them or pasting
38
+ them into an online Mermaid viewer. Diagnostic redaction is a limited safeguard,
39
+ not a guarantee that every secret is removed; application logs are outside its
40
+ control.
@@ -0,0 +1,37 @@
1
+ # Active Record association support (Rails 7.2 / 8.1)
2
+
3
+ The target Rails series are 7.2 and 8.1. "Supported" describes behavior covered by the current implementation and tests; it does not imply support for every option of that association. The real-app matrix currently uses SQLite. PostgreSQL and MySQL have not been verified in a real-app run.
4
+
5
+ ## Implemented features
6
+
7
+ | Priority | Active Record feature | Status | Implemented behavior |
8
+ |---|---|---|---|
9
+ | P1-01 | Rails 7.2 / 8.1 runtime compatibility | Supported | Three Ruby/Rails pairs run through the local real-app matrix. |
10
+ | P1-02 | Association macro detection | Supported | Reflections are classified; unsupported macros produce diagnostics. |
11
+ | P1-03 | Direct `has_many` / `has_one` | Supported | Direct associations and self joins have verified cardinalities. |
12
+ | P1-04 | `inverse_of` | Supported | Physical relationships are merged into a canonical edge independently of an inverse declaration. |
13
+ | P1-05 | `has_many :through` / `has_one :through` | Supported | Inferred-source one-step and nested paths are rendered as semantic edges without duplicate endpoints. |
14
+ | P1-06 | Polymorphic associations | Supported | Selected matching inverse candidates produce one normalized edge per concrete target. |
15
+ | P1-07 | `has_and_belongs_to_many` | Supported | A hidden join table is verified and rendered as one normalized many-to-many relationship. |
16
+
17
+ ## Not yet supported
18
+
19
+ | Priority | Active Record feature | Needed for support |
20
+ |---|---|---|
21
+ | P2-01 | Scoped associations | Retain scope presence as metadata. |
22
+ | P2-02 | STI | Define how base and derived classes appear. |
23
+ | P2-03 | `delegated_type` | Render the delegator and concrete types. |
24
+ | P2-04 | Composite primary/foreign keys and `query_constraints` | Preserve and match key tuples. |
25
+ | P2-05 | Non-default `primary_key`, `source`, `source_type`, and `as` options | Resolve the target and key for each option. The supported polymorphic inverse case already uses `as:`. |
26
+ | P2-06 | Cross-domain and multi-database relationships | Represent external nodes or emit diagnostics. |
27
+ | P2-07 | `dependent`, `touch`, and `counter_cache` | Decide which behavior belongs in the diagram metadata. |
28
+ | P3-01 | `disable_joins`, `strict_loading`, and async | Decide whether execution properties belong in the diagram. |
29
+ | P3-02 | Association extensions | Represent or diagnose structural extensions. |
30
+
31
+ The supported baseline covers non-polymorphic, unscoped, scalar direct `belongs_to`; scalar `foreign_key` and `class_name`; conservative cardinalities from foreign keys, nullability, and unique indexes; same-domain ER/class output; diagnostics for excluded supported `belongs_to`; direct polymorphic `belongs_to` with selected `has_many` / `has_one ..., as:` inverse candidates; and unscoped scalar HABTM with a verified hidden join table.
32
+
33
+ ## References
34
+
35
+ - [P0 contract](https://github.com/AudioStakes/rails-mmd/blob/main/docs/p0-contract.md)
36
+ - [Relationship builder](../lib/rails_mmd/relationship_builder.rb) and [render plan builder](../lib/rails_mmd/render_plan_builder.rb)
37
+ - [Rails Association Basics](https://guides.rubyonrails.org/association_basics.html), [Rails 7.2 API](https://api.rubyonrails.org/v7.2/classes/ActiveRecord/Associations/ClassMethods.html), [Rails 8.1 API](https://api.rubyonrails.org/v8.1/classes/ActiveRecord/Associations/ClassMethods.html)
data/docs/usage.md ADDED
@@ -0,0 +1,126 @@
1
+ # rails-mmd Usage
2
+
3
+ Run `rails-mmd generate` from the **target Rails application's root**, with `rails-mmd` in that application's `Gemfile` (see the [quick start](../README.md)). The command loads and eager-loads the Rails app, reads metadata for selected models, builds Mermaid render plans, and publishes local artifacts. Use an environment that can boot the app and read its database schema.
4
+
5
+ Booting also executes the application's initializers. Use an application and environment you trust; the [security policy](../SECURITY.md) describes this boundary and how to handle generated schema information.
6
+
7
+ ## Command
8
+
9
+ ```sh
10
+ RAILS_ENV=development bundle exec rails-mmd generate \
11
+ --config rails_mmd.yml \
12
+ --output-dir tmp/rails_mmd \
13
+ --domain core \
14
+ --format both \
15
+ --fail-on-warning
16
+ ```
17
+
18
+ `--config` is optional as an option, but the resolved configuration file must exist. Paths for output are relative to the application root; the output directory must stay within it. `--config` may be an absolute path.
19
+
20
+ | Option | Default | Meaning |
21
+ |---|---|---|
22
+ | `--config PATH` | `rails_mmd.yml` | Configuration file to load |
23
+ | `--output-dir DIR` | Config value, then `docs/rails_mmd` | Application-root-relative artifact directory |
24
+ | `--domain ID` | All configured domains | Render one configured domain |
25
+ | `--format er\|class\|both` | Config value, then `both` | Artifact formats to publish |
26
+ | `--fail-on-warning` | False | Return exit `1` when warning diagnostics are present |
27
+
28
+ ## Minimal configuration
29
+
30
+ ```yaml
31
+ version: 1
32
+ domains:
33
+ core:
34
+ include_models:
35
+ - User
36
+ exclude_models: []
37
+ output:
38
+ directory: tmp/rails_mmd
39
+ format: both
40
+ attributes: keys
41
+ direction: LR
42
+ ```
43
+
44
+ Replace `User` with a named Active Record model in your app. `include_models` is required and must be nonempty. A model must be loaded and have usable table metadata. The command does not search for models automatically or install Mermaid CLI.
45
+
46
+ ## Configuration reference
47
+
48
+ Unknown keys are rejected. The [configuration schema](../schemas/config.schema.json) is the machine-readable reference.
49
+
50
+ | Key | Default | Allowed values / meaning |
51
+ |---|---|---|
52
+ | `version` | Required | Integer `1` |
53
+ | `domains` | Required | One or more named model groups |
54
+ | `domains.<id>.include_models` | Required | Nonempty list of exact Ruby constant names, such as `User` or `Billing::Invoice` |
55
+ | `domains.<id>.exclude_models` | `[]` | Exact model names to remove from that domain's included set |
56
+ | `output.directory` | `docs/rails_mmd` | Relative path inside the application root |
57
+ | `output.format` | `both` | `er`, `class`, or `both` |
58
+ | `output.attributes` | `keys` | `keys` shows primary and foreign keys; `none` hides attributes |
59
+ | `output.direction` | `LR` | `LR` left to right, `RL` right to left, `TB` top to bottom, `BT` bottom to top |
60
+
61
+ Domain IDs use lowercase letters and digits, start with a letter, and may contain single underscores between nonempty segments: `core` and `billing_v2` are valid; `Core`, `billing-v2`, and `billing__v2` are not. Model names are case-sensitive; wildcards and regular expressions are not supported. Each domain renders independently. Include both endpoints of a relationship in the same domain to display it.
62
+
63
+ CLI values for `--output-dir` and `--format` override their YAML equivalents. `--domain` selects one configured domain; omitting it renders all domains. `attributes` and `direction` are YAML settings only.
64
+
65
+ ## Viewing a generated diagram
66
+
67
+ The `.mmd` files contain Mermaid source, not an image or an HTML page. Open them in a local viewer with Mermaid support, or copy the contents of one file into a Markdown file between these fences:
68
+
69
+ ````markdown
70
+ ```mermaid
71
+ <paste the contents of core.er.mmd here, replacing this line>
72
+ ```
73
+ ````
74
+
75
+ Open that Markdown file in a viewer that renders Mermaid code blocks. An ordinary text editor displays the source only. The illustrative diagram in the [README](../README.md) demonstrates this embedding. Keep private application diagrams in a local viewer; copying them to a hosted editor shares their schema content with that service.
76
+
77
+ ## Artifacts
78
+
79
+ Successful domains can publish:
80
+
81
+ - `<domain>.diagnostics.json`
82
+ - `<domain>.er.mmd`
83
+ - `<domain>.er.render_plan.json`
84
+ - `<domain>.class.mmd`
85
+ - `<domain>.class.render_plan.json`
86
+
87
+ `global.diagnostics.json` is published only for post-output invocation-scope diagnostics when applicable. Empty diagnostics files are omitted. A run replaces the managed artifacts for selected domains in the output directory; keep unrelated files separate from these names.
88
+
89
+ Blocking diagnostics usually publish diagnostics JSON when output is available and omit the affected Mermaid and render-plan artifacts. Publish failures may fall back to sanitized stderr and leave previous managed artifacts intact. Pre-output fatal failures write sanitized diagnostics to stderr and write no files.
90
+
91
+ The artifacts include application schema information and can include connection identifiers in diagnostics. Review them before sharing or committing them; use a private or ignored output directory for private schemas.
92
+
93
+ ## Exit codes
94
+
95
+ | Exit | Meaning |
96
+ |---:|---|
97
+ | 0 | success, including warning diagnostics when `--fail-on-warning` is absent |
98
+ | 1 | warning diagnostics were present and `--fail-on-warning` was set |
99
+ | 2 | config, output setup, Rails boot, domain, or schema probe error |
100
+ | 3 | Mermaid serialization failure or unresolved safe-token collision |
101
+ | 4 | output write or atomic publish failure |
102
+ | 99 | internal error |
103
+
104
+ CLI argument errors also return exit `1` before generation starts. These include unknown options, unexpected positional arguments, missing option values, and invalid boolean values. Check stderr for the argument error.
105
+
106
+ If the command reports a Rails load failure, check that you ran it from the application's root, in its bundle, and that the selected Rails environment can boot. If a model is missing, check its constant name and whether it was loaded by Rails. Domain and association limits are described in the [support matrix](active-record-association-support.md).
107
+
108
+ ## Troubleshooting
109
+
110
+ Start with `bundle exec rails-mmd generate --help` to verify that the executable is available in the application's bundle. After generation, inspect `<domain>.diagnostics.json` in the output directory, along with stderr. A warning can omit an unsupported relationship while still returning exit `0`; use `--fail-on-warning` when omissions should fail a check.
111
+
112
+ | Symptom | What to check |
113
+ |---|---|
114
+ | Bundler cannot find `rails-mmd` | Add the gem to the target application's Gemfile and run `bundle install` there. |
115
+ | Unknown option, unexpected argument, or invalid boolean | Compare the command with `bundle exec rails-mmd generate --help`. Pass model names through YAML, not positional arguments. Use `--fail-on-warning` as a flag, or set it explicitly to `true` or `false`. |
116
+ | Configuration file not found or invalid | Create `rails_mmd.yml` in the application root; compare keys and values with the configuration reference above. |
117
+ | Rails boot or database metadata error | Use the application's usual setup instructions to prepare its dependencies, environment, and database. Run `RAILS_ENV=development bundle exec rails runner 'puts Rails.env'` to isolate boot failures. |
118
+ | Model not found | Use its full constant name, including namespace, and ensure Rails eager loading can load it. |
119
+ | Expected relationship is missing | Include both models in the same domain, inspect warning diagnostics, and check the support matrix for unsupported options. |
120
+ | Output directory rejected | Use an application-root-relative path such as `tmp/rails_mmd`; paths escaping the root are rejected. |
121
+ | Output write or publish failure | Check stderr and inspect the output directory before retrying; follow the recovery steps below. |
122
+ | Diagram appears as text | Use a Mermaid-capable viewer or the Markdown embedding instructions above. |
123
+
124
+ If publication and rollback both fail, originals that could not be restored remain in a hidden `.rails-mmd-backup-*` directory inside the output directory. Inspect those backups first. Correct the filesystem problem, such as permissions or insufficient disk space, then manually restore the originals you need and verify them before running generation again. Keep the backups until recovery is confirmed.
125
+
126
+ `rails-mmd generate` does not invoke Mermaid CLI or any hosted service.
data/exe/rails-mmd ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'rails_mmd/cli'
5
+
6
+ RailsMmd::CLI.start(ARGV)
@@ -0,0 +1,39 @@
1
+ {
2
+ "schema_version": 1,
3
+ "scope": "domain",
4
+ "domain_id": "core",
5
+ "diagnostics": [
6
+ { "diagnostic_id": "d_config_not_found", "code": "CONFIG_NOT_FOUND", "severity": "error", "phase": "config", "scope": "invocation", "subject_id": null, "message": "config missing", "metadata": { "config_path": "rails_mmd.yml" }, "artifact_refs": [], "remediation": null },
7
+ { "diagnostic_id": "d_config_schema_invalid", "code": "CONFIG_SCHEMA_INVALID", "severity": "error", "phase": "config", "scope": "invocation", "subject_id": null, "message": "invalid config", "metadata": { "config_path": "rails_mmd.yml", "field_path": "$.domains" }, "artifact_refs": [], "remediation": null },
8
+ { "diagnostic_id": "d_config_domain_not_found", "code": "CONFIG_DOMAIN_NOT_FOUND", "severity": "error", "phase": "config", "scope": "domain", "subject_id": "missing", "message": "domain missing", "metadata": { "domain_id": "missing" }, "artifact_refs": [], "remediation": null },
9
+ { "diagnostic_id": "d_output_directory_invalid", "code": "OUTPUT_DIRECTORY_INVALID", "severity": "error", "phase": "output_setup", "scope": "invocation", "subject_id": null, "message": "bad output dir", "metadata": { "field_path": "$.output.directory", "reason": "absolute" }, "artifact_refs": [], "remediation": null },
10
+ { "diagnostic_id": "d_domain_model_not_found", "code": "DOMAIN_MODEL_NOT_FOUND", "severity": "error", "phase": "domain_resolution", "scope": "domain", "subject_id": "core:MissingUser", "message": "model missing", "metadata": { "domain_id": "core", "ruby_constant": "MissingUser" }, "artifact_refs": [], "remediation": null },
11
+ { "diagnostic_id": "d_domain_model_not_renderable", "code": "DOMAIN_MODEL_NOT_RENDERABLE", "severity": "error", "phase": "domain_resolution", "scope": "model", "subject_id": "core:Admin::User", "message": "model not renderable", "metadata": { "domain_id": "core", "ruby_constant": "Admin::User", "renderability_reason": "abstract" }, "artifact_refs": [], "remediation": null },
12
+ { "diagnostic_id": "d_domain_empty", "code": "DOMAIN_EMPTY", "severity": "error", "phase": "domain_resolution", "scope": "domain", "subject_id": "core", "message": "domain empty", "metadata": { "domain_id": "core" }, "artifact_refs": [], "remediation": null },
13
+ { "diagnostic_id": "d_rails_load_failed", "code": "RAILS_LOAD_FAILED", "severity": "error", "phase": "rails_load", "scope": "invocation", "subject_id": null, "message": "rails load failed", "metadata": { "exception_class": "RuntimeError", "exception_summary": "load failed", "backtrace": null }, "artifact_refs": [], "remediation": null },
14
+ { "diagnostic_id": "d_rails_eager_load_failed", "code": "RAILS_EAGER_LOAD_FAILED", "severity": "error", "phase": "rails_load", "scope": "invocation", "subject_id": null, "message": "eager load failed", "metadata": { "exception_class": "RuntimeError", "exception_summary": "eager failed", "backtrace": null }, "artifact_refs": [], "remediation": null },
15
+ { "diagnostic_id": "d_multi_db_unsupported", "code": "MULTI_DB_UNSUPPORTED", "severity": "error", "phase": "schema_probe", "scope": "domain", "subject_id": "core", "message": "multi database unsupported", "metadata": { "domain_id": "core", "connection_context_ids": ["{\"name\":\"primary\",\"role\":\"writing\",\"shard\":\"default\"}", "{\"name\":\"animals\",\"role\":\"writing\",\"shard\":\"default\"}"] }, "artifact_refs": [], "remediation": null },
16
+ { "diagnostic_id": "d_model_table_missing", "code": "MODEL_TABLE_MISSING", "severity": "error", "phase": "schema_probe", "scope": "model", "subject_id": "core:User", "message": "table missing", "metadata": { "domain_id": "core", "ruby_constant": "User", "table_name": "users" }, "artifact_refs": [], "remediation": null },
17
+ { "diagnostic_id": "d_model_primary_key_unsupported", "code": "MODEL_PRIMARY_KEY_UNSUPPORTED", "severity": "error", "phase": "schema_probe", "scope": "model", "subject_id": "core:User", "message": "pk unsupported", "metadata": { "domain_id": "core", "ruby_constant": "User", "table_name": "users" }, "artifact_refs": [], "remediation": null },
18
+ { "diagnostic_id": "d_association_target_unresolved", "code": "ASSOCIATION_TARGET_UNRESOLVED", "severity": "warning", "phase": "relationship_build", "scope": "relationship", "subject_id": "users.account", "message": "target unresolved", "metadata": { "domain_id": "core", "owner_constant": "User", "association_name": "account", "target_constant": "Account" }, "artifact_refs": [], "remediation": null },
19
+ { "diagnostic_id": "d_association_through_unresolved", "code": "ASSOCIATION_THROUGH_UNRESOLVED", "severity": "warning", "phase": "relationship_build", "scope": "relationship", "subject_id": "authors.teams", "message": "through reflection unresolved", "metadata": { "domain_id": "core", "owner_constant": "Author", "association_name": "teams" }, "artifact_refs": [], "remediation": null },
20
+ { "diagnostic_id": "d_association_source_unresolved", "code": "ASSOCIATION_SOURCE_UNRESOLVED", "severity": "warning", "phase": "relationship_build", "scope": "relationship", "subject_id": "authors.teams", "message": "source reflection unresolved", "metadata": { "domain_id": "core", "owner_constant": "Author", "association_name": "teams" }, "artifact_refs": [], "remediation": null },
21
+ { "diagnostic_id": "d_association_target_not_renderable_omitted", "code": "ASSOCIATION_TARGET_NOT_RENDERABLE_OMITTED", "severity": "warning", "phase": "relationship_build", "scope": "relationship", "subject_id": "users.account", "message": "target omitted", "metadata": { "domain_id": "core", "owner_constant": "User", "association_name": "account", "target_constant": "Account" }, "artifact_refs": [], "remediation": null },
22
+ { "diagnostic_id": "d_association_polymorphic_omitted", "code": "ASSOCIATION_POLYMORPHIC_OMITTED", "severity": "warning", "phase": "relationship_build", "scope": "relationship", "subject_id": "comments.commentable", "message": "polymorphic omitted", "metadata": { "domain_id": "core", "owner_constant": "Comment", "association_name": "commentable" }, "artifact_refs": [], "remediation": null },
23
+ { "diagnostic_id": "d_association_polymorphic_targets_unresolved", "code": "ASSOCIATION_POLYMORPHIC_TARGETS_UNRESOLVED", "severity": "warning", "phase": "relationship_build", "scope": "relationship", "subject_id": "comments.commentable", "message": "polymorphic targets unresolved", "metadata": { "domain_id": "core", "owner_constant": "Comment", "association_name": "commentable" }, "artifact_refs": [], "remediation": null },
24
+ { "diagnostic_id": "d_association_join_table_invalid", "code": "ASSOCIATION_JOIN_TABLE_INVALID", "severity": "warning", "phase": "relationship_build", "scope": "relationship", "subject_id": "authors.tags", "message": "join table invalid", "metadata": { "domain_id": "core", "owner_constant": "Author", "association_name": "tags", "target_constant": "Tag", "join_table": "authors_tags", "reason": "unresolved" }, "artifact_refs": [], "remediation": null },
25
+ { "diagnostic_id": "d_association_scoped_omitted", "code": "ASSOCIATION_SCOPED_OMITTED", "severity": "warning", "phase": "relationship_build", "scope": "relationship", "subject_id": "orders.account", "message": "scoped omitted", "metadata": { "domain_id": "core", "owner_constant": "Order", "association_name": "account" }, "artifact_refs": [], "remediation": null },
26
+ { "diagnostic_id": "d_association_composite_key_omitted", "code": "ASSOCIATION_COMPOSITE_KEY_OMITTED", "severity": "warning", "phase": "relationship_build", "scope": "relationship", "subject_id": "orders.account", "message": "composite key omitted", "metadata": { "domain_id": "core", "owner_constant": "Order", "association_name": "account" }, "artifact_refs": [], "remediation": null },
27
+ { "diagnostic_id": "d_association_key_column_missing", "code": "ASSOCIATION_KEY_COLUMN_MISSING", "severity": "warning", "phase": "relationship_build", "scope": "relationship", "subject_id": "orders.account", "message": "key column missing", "metadata": { "domain_id": "core", "owner_constant": "Order", "association_name": "account" }, "artifact_refs": [], "remediation": null },
28
+ { "diagnostic_id": "d_association_non_primary_key_omitted", "code": "ASSOCIATION_NON_PRIMARY_KEY_OMITTED", "severity": "warning", "phase": "relationship_build", "scope": "relationship", "subject_id": "orders.account", "message": "non primary key omitted", "metadata": { "domain_id": "core", "owner_constant": "Order", "association_name": "account" }, "artifact_refs": [], "remediation": null },
29
+ { "diagnostic_id": "d_association_name_unsupported_omitted", "code": "ASSOCIATION_NAME_UNSUPPORTED_OMITTED", "severity": "warning", "phase": "relationship_build", "scope": "relationship", "subject_id": "orders.Account", "message": "association name unsupported", "metadata": { "domain_id": "core", "owner_constant": "Order", "association_name": "Account" }, "artifact_refs": [], "remediation": null },
30
+ { "diagnostic_id": "d_association_macro_omitted", "code": "ASSOCIATION_MACRO_OMITTED", "severity": "warning", "phase": "relationship_build", "scope": "relationship", "subject_id": "authors.posts", "message": "association macro omitted", "metadata": { "domain_id": "core", "owner_constant": "Author", "association_name": "posts", "association_macro": "has_many" }, "artifact_refs": [], "remediation": null },
31
+ { "diagnostic_id": "d_domain_relationship_omitted", "code": "DOMAIN_RELATIONSHIP_OMITTED", "severity": "warning", "phase": "relationship_build", "scope": "relationship", "subject_id": "orders.account", "message": "outside domain omitted", "metadata": { "domain_id": "core", "owner_constant": "Order", "association_name": "account", "target_constant": "Account" }, "artifact_refs": [], "remediation": null },
32
+ { "diagnostic_id": "d_db_metadata_degraded", "code": "DB_METADATA_DEGRADED", "severity": "warning", "phase": "schema_probe", "scope": "domain", "subject_id": "core", "message": "metadata degraded", "metadata": { "domain_id": "core", "ruby_constant": "User", "metadata_kind": "foreign_key", "reason": "permission" }, "artifact_refs": [], "remediation": null },
33
+ { "diagnostic_id": "d_safe_token_collision", "code": "SAFE_TOKEN_COLLISION", "severity": "warning", "phase": "tokenization", "scope": "artifact", "subject_id": "core:er", "message": "token collision resolved", "metadata": { "artifact_kind": "er", "domain_id": "core", "token_kind": "entity", "base_safe_token": "USER", "collision_subject_count": 2, "resolved": true }, "artifact_refs": [{ "artifact_kind": "render_plan", "domain_id": "core", "path": "core.er.render_plan.json" }], "remediation": null },
34
+ { "diagnostic_id": "d_mermaid_serialization_failed", "code": "MERMAID_SERIALIZATION_FAILED", "severity": "error", "phase": "serialization", "scope": "artifact", "subject_id": "core:er", "message": "serialization failed", "metadata": { "artifact_kind": "er", "domain_id": "core", "reason": "invalid token" }, "artifact_refs": [], "remediation": null },
35
+ { "diagnostic_id": "d_output_write_failed", "code": "OUTPUT_WRITE_FAILED", "severity": "error", "phase": "publish", "scope": "invocation", "subject_id": null, "message": "write failed", "metadata": { "operation": "rename", "path": "core.er.mmd" }, "artifact_refs": [], "remediation": null },
36
+ { "diagnostic_id": "d_internal_error", "code": "INTERNAL_ERROR", "severity": "error", "phase": "internal", "scope": "invocation", "subject_id": null, "message": "internal error", "metadata": { "exception_class": "RuntimeError", "exception_summary": "unexpected", "backtrace": null }, "artifact_refs": [], "remediation": null }
37
+ ],
38
+ "digest_sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
39
+ }
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails_mmd/redactor'
4
+
5
+ module RailsMmd
6
+ # Sanitizes and validates diagnostic artifact references.
7
+ class ArtifactRefSanitizer
8
+ ALLOWED_KEYS = %w[artifact_kind domain_id path].freeze
9
+ ARTIFACT_KINDS = %w[diagnostics er class render_plan stderr].freeze
10
+ DOMAIN_ID_PATTERN = /\A[a-z][a-z0-9]*(?:_[a-z0-9]+)*\z/
11
+ PATH_PATTERN = %r{\A(?!/)(?!.*(?:^|/)\.\.(?:/|$))(?!.*//)[A-Za-z0-9._/-]+\z}
12
+ REQUIRED_KEYS = %w[artifact_kind domain_id].freeze
13
+
14
+ def initialize(redactor: Redactor.new)
15
+ @redactor = redactor
16
+ end
17
+
18
+ def sanitize(artifact_refs)
19
+ artifact_refs.map do |artifact_ref|
20
+ sanitize_reference(artifact_ref)
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ attr_reader :redactor
27
+
28
+ def sanitize_reference(artifact_ref)
29
+ reference = artifact_ref.to_h.transform_keys(&:to_s)
30
+ unknown_keys = reference.keys - ALLOWED_KEYS
31
+ raise ArgumentError, "unknown artifact ref keys: #{unknown_keys.join(', ')}" unless unknown_keys.empty?
32
+
33
+ validate_reference!(reference)
34
+ reference['path'] = safe_path(reference.fetch('path')) if reference.key?('path')
35
+ reference
36
+ end
37
+
38
+ def validate_reference!(reference)
39
+ missing_keys = REQUIRED_KEYS - reference.keys
40
+ raise ArgumentError, "missing artifact ref keys: #{missing_keys.join(', ')}" unless missing_keys.empty?
41
+ unless ARTIFACT_KINDS.include?(reference.fetch('artifact_kind'))
42
+ raise ArgumentError,
43
+ "invalid artifact_kind: #{reference.fetch('artifact_kind')}"
44
+ end
45
+
46
+ domain_id = reference.fetch('domain_id')
47
+ return if domain_id.nil? || domain_id.match?(DOMAIN_ID_PATTERN)
48
+
49
+ raise ArgumentError, "invalid artifact domain_id: #{domain_id}"
50
+ end
51
+
52
+ def safe_path(path)
53
+ path = redactor.sanitize(path)
54
+ return path if path.match?(PATH_PATTERN)
55
+
56
+ raise ArgumentError, "invalid artifact path: #{path}"
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsMmd
4
+ # Normalizes DB/Rails attribute type hints for Mermaid-facing render plans.
5
+ module AttributeTypes
6
+ SUPPORTED_TYPES = %w[
7
+ bigint
8
+ boolean
9
+ date
10
+ datetime
11
+ decimal
12
+ float
13
+ integer
14
+ string
15
+ text
16
+ time
17
+ unknown
18
+ ].freeze
19
+
20
+ module_function
21
+
22
+ def normalize(value)
23
+ type = value.to_s.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/\A_+|_+\z/, '')
24
+ return type if SUPPORTED_TYPES.include?(type)
25
+
26
+ 'unknown'
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+ require 'json/canonicalization'
5
+
6
+ module RailsMmd
7
+ # RFC 8785/JCS canonical JSON helpers.
8
+ module CanonicalJson
9
+ module_function
10
+
11
+ def dump(value)
12
+ value.to_json_c14n
13
+ end
14
+
15
+ def digest_sha256(value)
16
+ Digest::SHA256.hexdigest(dump(value))
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'rails_mmd/config'
5
+ require 'rails_mmd/generate_command'
6
+ require_relative 'version'
7
+
8
+ module RailsMmd
9
+ # Minimal Thor shell for smokeable help and version output.
10
+ class CLI < Thor
11
+ MISSING_OPTION_VALUE = Object.new.freeze
12
+ GENERATE_HELP = <<~HELP
13
+ Usage:
14
+ rails-mmd generate
15
+
16
+ Options:
17
+ [--config=CONFIG] Required YAML file (default: rails_mmd.yml)
18
+ [--output-dir=OUTPUT_DIR] Project-relative directory (default: docs/rails_mmd)
19
+ [--domain=DOMAIN] One configured domain ID (default: all domains)
20
+ [--format=FORMAT] er, class, or both (default: both)
21
+ [--fail-on-warning] Exit 1 if any warning is emitted (default: false)
22
+
23
+ Run from the target Rails application root with bundle exec rails-mmd generate.
24
+ Config output.directory and output.format override the defaults; CLI options take precedence.
25
+ HELP
26
+
27
+ package_name 'rails-mmd'
28
+ remove_command :tree
29
+ default_task :help
30
+ check_unknown_options! only: :generate
31
+
32
+ def self.exit_on_failure?
33
+ true
34
+ end
35
+
36
+ desc 'version', 'Print rails-mmd version'
37
+ map '--version' => :version, '-v' => :version
38
+ def version
39
+ say RailsMmd::VERSION
40
+ end
41
+
42
+ desc 'generate', 'Generate Mermaid diagrams from a Rails app'
43
+ method_option :config, type: :string, lazy_default: MISSING_OPTION_VALUE
44
+ method_option :output_dir, type: :string, lazy_default: MISSING_OPTION_VALUE
45
+ method_option :domain, type: :string, lazy_default: MISSING_OPTION_VALUE
46
+ method_option :format, type: :string, lazy_default: MISSING_OPTION_VALUE
47
+ method_option :fail_on_warning, type: :string
48
+ method_option :help, type: :boolean, hide: true, aliases: '-h'
49
+ def generate(*args)
50
+ return say(GENERATE_HELP) if options[:help]
51
+ raise Thor::Error, 'Unexpected arguments. Run rails-mmd generate --help for usage.' unless args.empty?
52
+
53
+ result = GenerateCommand.new.run(cli_options: cli_options, fail_on_warning: fail_on_warning?)
54
+ emit_result(result)
55
+ end
56
+
57
+ no_commands do
58
+ def cli_options
59
+ missing = options.key(MISSING_OPTION_VALUE)
60
+ raise Thor::Error, "No value provided for option '--#{missing.tr('_', '-')}'" if missing
61
+
62
+ Config::CliOptions.new(
63
+ config_path: options[:config],
64
+ output_dir: options[:output_dir],
65
+ domain: options[:domain],
66
+ format: options[:format]
67
+ )
68
+ end
69
+
70
+ def emit_result(result)
71
+ print result.stdout unless result.stdout.empty?
72
+ warn result.stderr unless result.stderr.empty?
73
+ exit(result.exit_code)
74
+ end
75
+
76
+ def fail_on_warning?
77
+ return false if options[:fail_on_warning].nil?
78
+ return false if options[:fail_on_warning] == 'false'
79
+ return true if options[:fail_on_warning] == true
80
+ return true if %w[true fail_on_warning].include?(options[:fail_on_warning]) || options[:fail_on_warning].empty?
81
+
82
+ raise Thor::Error, '--fail-on-warning must be true or false'
83
+ end
84
+ end
85
+ end
86
+ end