sidenotes 0.1.2 → 0.2.1
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 +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +31 -0
- data/lib/generators/sidenotes/templates/initializer.rb +4 -0
- data/lib/sidenotes/configuration.rb +6 -1
- data/lib/sidenotes/railtie.rb +65 -0
- data/lib/sidenotes/verifier.rb +169 -0
- data/lib/sidenotes/version.rb +1 -1
- data/lib/sidenotes.rb +1 -0
- metadata +3 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 49cfca99965003c2f6fbff02ac2d9a930671bec5bb36f0cafecbb03e8c2e47f1
|
|
4
|
+
data.tar.gz: 505db15a10c3df2c3af8d3d3425d1cb3ea4580faf22a24fe0ba10a48dd0c432d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 042f58ab4161438219e6980cd8637bb07395e91bd4240e1c7bc729362c3a27d99b0d67cd2ff91c17103a58deb2fd6fa830de42749bdc1bceadc44fd7c7fb6334
|
|
7
|
+
data.tar.gz: 6e621545a5d90913fa388317bec1fc84bd75c7f0e2407be88f701eeff6d4d957a5f1b949379475b4ae4668edecf6af72dfd42db3adebb19b1b58fdde5f7ff808
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.2.1] - 2026-05-01
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `sidenotes:verify` now detects pending migrations and reports them as drift (exits 1). This catches the case where a migration file exists but hasn't been applied, where the sidecars and DB happen to match but the test suite would later fail with `ActiveRecord::PendingMigrationError`.
|
|
13
|
+
- Verifier API: `Sidenotes::Verifier#pending_migrations`, `#pending_migrations?`
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
|
|
17
|
+
- `Verifier#drift?` and `#issue_count` now include pending migrations.
|
|
18
|
+
- Migration context lookup supports both pre-7.2 (`connection.migration_context`) and 7.2+ (`connection_pool.migration_context`) APIs.
|
|
19
|
+
|
|
20
|
+
## [0.2.0] - 2026-04-29
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `sidenotes:verify` rake task that compares sidecar files against the database schema, reports drift, and exits non-zero on mismatch (suitable for CI)
|
|
25
|
+
- `Sidenotes::Verifier` class for programmatic drift detection
|
|
26
|
+
- `auto_generate_after_migrate` configuration option that hooks into `db:migrate`, `db:migrate:up`, `db:migrate:down`, and `db:rollback` to regenerate annotations automatically
|
|
27
|
+
- `SIDENOTES_AUTO_GENERATE` env var as a zero-config alternative to the configuration flag
|
|
28
|
+
|
|
8
29
|
## [0.1.0] - 2026-04-08
|
|
9
30
|
|
|
10
31
|
### Added
|
data/README.md
CHANGED
|
@@ -73,6 +73,37 @@ rake sidenotes:model MODEL=User
|
|
|
73
73
|
rake sidenotes:clean
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
### Verify annotations match the database
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
rake sidenotes:verify
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Compares each `.annotations/` sidecar file against the live database schema and reports drift (missing/extra columns, indexes, associations, foreign keys, or models without sidecars). It also surfaces **pending migrations** — if a migration file exists but hasn't been applied, sidenotes flags it before doing the schema comparison, since the test suite would fail later anyway. Exits with status `1` if any drift or pending migrations are detected, making it suitable for CI:
|
|
83
|
+
|
|
84
|
+
```yaml
|
|
85
|
+
# .github/workflows/ci.yml
|
|
86
|
+
- run: bundle exec rake sidenotes:verify
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
### Auto-regenerate after migrations
|
|
90
|
+
|
|
91
|
+
Sidenotes can hook into `db:migrate`, `db:migrate:up`, `db:migrate:down`, and `db:rollback` so annotations stay in sync with your schema automatically. Enable it in your initializer:
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
Sidenotes.configure do |config|
|
|
95
|
+
config.auto_generate_after_migrate = true
|
|
96
|
+
end
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Or via environment variable, no initializer required:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
SIDENOTES_AUTO_GENERATE=true bin/rails db:migrate
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
When enabled, every successful migration triggers `sidenotes:generate` automatically. Keep this opt-in so it doesn't run in CI or production environments unintentionally.
|
|
106
|
+
|
|
76
107
|
### Example output
|
|
77
108
|
|
|
78
109
|
Running `rake sidenotes:generate` creates `.annotations/user.yml`:
|
|
@@ -18,4 +18,8 @@ Sidenotes.configure do |config|
|
|
|
18
18
|
|
|
19
19
|
# Patterns to exclude models (strings or regexps)
|
|
20
20
|
# config.exclude_patterns = ["ApplicationRecord", /^HABTM_/]
|
|
21
|
+
|
|
22
|
+
# Auto-regenerate annotations after db:migrate, db:rollback, etc.
|
|
23
|
+
# Can also be enabled via the SIDENOTES_AUTO_GENERATE=true env var.
|
|
24
|
+
# config.auto_generate_after_migrate = true
|
|
21
25
|
end
|
|
@@ -7,7 +7,7 @@ module Sidenotes
|
|
|
7
7
|
DEFAULT_SECTIONS = %i[columns indexes associations foreign_keys metadata].freeze
|
|
8
8
|
|
|
9
9
|
attr_reader :output_directory, :format, :sections
|
|
10
|
-
attr_accessor :model_paths, :exclude_patterns
|
|
10
|
+
attr_accessor :model_paths, :exclude_patterns, :auto_generate_after_migrate
|
|
11
11
|
|
|
12
12
|
def initialize
|
|
13
13
|
@output_directory = '.annotations'
|
|
@@ -15,6 +15,11 @@ module Sidenotes
|
|
|
15
15
|
@sections = DEFAULT_SECTIONS.dup
|
|
16
16
|
@model_paths = ['app/models']
|
|
17
17
|
@exclude_patterns = []
|
|
18
|
+
@auto_generate_after_migrate = false
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def auto_generate_after_migrate?
|
|
22
|
+
auto_generate_after_migrate || ENV['SIDENOTES_AUTO_GENERATE'] == 'true'
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
def format=(value)
|
data/lib/sidenotes/railtie.rb
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Sidenotes
|
|
4
4
|
class Railtie < Rails::Railtie
|
|
5
|
+
MIGRATE_TASKS = %w[db:migrate db:migrate:up db:migrate:down db:rollback].freeze
|
|
6
|
+
|
|
5
7
|
rake_tasks do
|
|
6
8
|
namespace :sidenotes do
|
|
7
9
|
desc 'Generate annotation files for all models'
|
|
@@ -19,7 +21,14 @@ module Sidenotes
|
|
|
19
21
|
task model: :environment do
|
|
20
22
|
Sidenotes::Railtie.run_model
|
|
21
23
|
end
|
|
24
|
+
|
|
25
|
+
desc 'Verify annotation files match the database schema (exits 1 on drift)'
|
|
26
|
+
task verify: :environment do
|
|
27
|
+
Sidenotes::Railtie.run_verify
|
|
28
|
+
end
|
|
22
29
|
end
|
|
30
|
+
|
|
31
|
+
Sidenotes::Railtie.install_migrate_hooks if Sidenotes.configuration.auto_generate_after_migrate?
|
|
23
32
|
end
|
|
24
33
|
|
|
25
34
|
def self.run_generate
|
|
@@ -48,5 +57,61 @@ module Sidenotes
|
|
|
48
57
|
puts "Sidenotes: Could not generate annotation for #{model_name}"
|
|
49
58
|
end
|
|
50
59
|
end
|
|
60
|
+
|
|
61
|
+
def self.run_verify
|
|
62
|
+
verifier = Sidenotes::Verifier.new.verify_all
|
|
63
|
+
print_verify_report(verifier)
|
|
64
|
+
exit(1) if verifier.drift?
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.print_verify_report(verifier)
|
|
68
|
+
puts "Checking #{verifier.checked_count} model annotations against database..."
|
|
69
|
+
puts
|
|
70
|
+
|
|
71
|
+
print_pending_migrations(verifier) if verifier.pending_migrations?
|
|
72
|
+
print_drifts(verifier)
|
|
73
|
+
print_missing_sidecars(verifier)
|
|
74
|
+
print_summary(verifier)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def self.print_pending_migrations(verifier)
|
|
78
|
+
puts ' PENDING MIGRATIONS detected (run `rails db:migrate` first):'
|
|
79
|
+
verifier.pending_migrations.each do |m|
|
|
80
|
+
puts " - #{m['version']} #{m['name']}"
|
|
81
|
+
end
|
|
82
|
+
puts
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def self.print_drifts(verifier)
|
|
86
|
+
verifier.drifts.each do |drift|
|
|
87
|
+
puts " #{File.basename(drift.sidecar_path)} - DRIFT:"
|
|
88
|
+
drift.issues.each { |issue| puts " #{issue}" }
|
|
89
|
+
puts
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def self.print_missing_sidecars(verifier)
|
|
94
|
+
verifier.missing_sidecars.each do |name|
|
|
95
|
+
puts " #{name} - MISSING (model exists, no sidecar file)"
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def self.print_summary(verifier)
|
|
100
|
+
if verifier.drift?
|
|
101
|
+
puts "#{verifier.issue_count} issue(s) found. Run `rake db:migrate && rake sidenotes:generate` to update."
|
|
102
|
+
else
|
|
103
|
+
puts 'All annotations are up to date.'
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def self.install_migrate_hooks
|
|
108
|
+
MIGRATE_TASKS.each do |task_name|
|
|
109
|
+
next unless Rake::Task.task_defined?(task_name)
|
|
110
|
+
|
|
111
|
+
Rake::Task[task_name].enhance do
|
|
112
|
+
Rake::Task['sidenotes:generate'].invoke if Rake::Task.task_defined?('sidenotes:generate')
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
51
116
|
end
|
|
52
117
|
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
require 'json'
|
|
5
|
+
|
|
6
|
+
module Sidenotes
|
|
7
|
+
class Verifier
|
|
8
|
+
Drift = Struct.new(:model_name, :sidecar_path, :issues, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
attr_reader :drifts, :missing_sidecars, :checked_count, :pending_migrations
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@drifts = []
|
|
14
|
+
@missing_sidecars = []
|
|
15
|
+
@checked_count = 0
|
|
16
|
+
@pending_migrations = []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def verify_all
|
|
20
|
+
check_pending_migrations
|
|
21
|
+
generator = Generator.new
|
|
22
|
+
generator.discover_models.each { |model| verify_model(model) }
|
|
23
|
+
self
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def check_pending_migrations
|
|
27
|
+
context = migration_context
|
|
28
|
+
return unless context.respond_to?(:pending_migrations)
|
|
29
|
+
|
|
30
|
+
@pending_migrations = context.pending_migrations.map do |m|
|
|
31
|
+
{ 'version' => m.version, 'name' => m.name }
|
|
32
|
+
end
|
|
33
|
+
rescue StandardError => e
|
|
34
|
+
warn "Sidenotes: could not check pending migrations: #{e.message}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def pending_migrations?
|
|
38
|
+
pending_migrations.any?
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def verify_model(model)
|
|
42
|
+
inspector = ModelInspector.new(model)
|
|
43
|
+
return unless inspector.inspectable?
|
|
44
|
+
|
|
45
|
+
@checked_count += 1
|
|
46
|
+
sidecar = sidecar_path_for(model)
|
|
47
|
+
|
|
48
|
+
unless File.exist?(sidecar)
|
|
49
|
+
@missing_sidecars << model.name
|
|
50
|
+
return
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
stored = parse_sidecar(sidecar, model.name)
|
|
54
|
+
current = inspector.inspect_model
|
|
55
|
+
issues = compare(stored, current)
|
|
56
|
+
|
|
57
|
+
return if issues.empty?
|
|
58
|
+
|
|
59
|
+
@drifts << Drift.new(model_name: model.name, sidecar_path: sidecar, issues: issues)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def drift?
|
|
63
|
+
drifts.any? || missing_sidecars.any? || pending_migrations?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def issue_count
|
|
67
|
+
drifts.sum { |d| d.issues.size } + missing_sidecars.size + pending_migrations.size
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def migration_context
|
|
73
|
+
return nil unless defined?(ActiveRecord::Base)
|
|
74
|
+
|
|
75
|
+
pool = ActiveRecord::Base.connection_pool
|
|
76
|
+
return pool.migration_context if pool.respond_to?(:migration_context)
|
|
77
|
+
|
|
78
|
+
conn = ActiveRecord::Base.connection
|
|
79
|
+
return conn.migration_context if conn.respond_to?(:migration_context)
|
|
80
|
+
|
|
81
|
+
nil
|
|
82
|
+
rescue StandardError
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def sidecar_path_for(model)
|
|
87
|
+
config = Sidenotes.configuration
|
|
88
|
+
File.join(config.output_directory, "#{model.name.underscore}.#{config.file_extension}")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def parse_sidecar(path, model_name)
|
|
92
|
+
content = File.read(path)
|
|
93
|
+
parsed = case Sidenotes.configuration.format
|
|
94
|
+
when :yaml then YAML.safe_load(strip_yaml_header(content), permitted_classes: [Symbol])
|
|
95
|
+
when :json then JSON.parse(content)
|
|
96
|
+
end
|
|
97
|
+
parsed[model_name] || {}
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def strip_yaml_header(content)
|
|
101
|
+
content.lines.reject { |l| l.start_with?('#') }.join
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def compare(stored, current)
|
|
105
|
+
issues = []
|
|
106
|
+
issues.concat(diff_columns(stored['columns'], current['columns'])) if current.key?('columns')
|
|
107
|
+
issues.concat(diff_indexes(stored['indexes'], current['indexes'])) if current.key?('indexes')
|
|
108
|
+
if current.key?('foreign_keys')
|
|
109
|
+
issues.concat(diff_named_list(stored['foreign_keys'], current['foreign_keys'], 'foreign_key',
|
|
110
|
+
'name'))
|
|
111
|
+
end
|
|
112
|
+
issues.concat(diff_associations(stored['associations'], current['associations'])) if current.key?('associations')
|
|
113
|
+
issues
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def diff_columns(stored, current)
|
|
117
|
+
stored_by_name = (stored || []).to_h { |c| [c['name'], c] }
|
|
118
|
+
current_by_name = current.to_h { |c| [c['name'], c] }
|
|
119
|
+
|
|
120
|
+
added = (current_by_name.keys - stored_by_name.keys).map do |name|
|
|
121
|
+
"+ missing column: #{name} (#{current_by_name[name]['type']})"
|
|
122
|
+
end
|
|
123
|
+
removed = (stored_by_name.keys - current_by_name.keys).map do |name|
|
|
124
|
+
"- extra column: #{name} (no longer in schema)"
|
|
125
|
+
end
|
|
126
|
+
changed = column_type_changes(stored_by_name, current_by_name)
|
|
127
|
+
|
|
128
|
+
added + removed + changed
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def column_type_changes(stored_by_name, current_by_name)
|
|
132
|
+
(stored_by_name.keys & current_by_name.keys).filter_map do |name|
|
|
133
|
+
s = stored_by_name[name]
|
|
134
|
+
c = current_by_name[name]
|
|
135
|
+
"~ column changed: #{name} (#{s['type']} -> #{c['type']})" if s['type'] != c['type']
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def diff_indexes(stored, current)
|
|
140
|
+
stored ||= []
|
|
141
|
+
stored_names = stored.map { |i| i['name'] }
|
|
142
|
+
current_names = current.map { |i| i['name'] }
|
|
143
|
+
|
|
144
|
+
issues = (current_names - stored_names).map { |n| "+ missing index: #{n}" }
|
|
145
|
+
(stored_names - current_names).each { |n| issues << "- extra index: #{n} (no longer in schema)" }
|
|
146
|
+
issues
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def diff_associations(stored, current)
|
|
150
|
+
stored ||= []
|
|
151
|
+
stored_keys = stored.map { |a| "#{a['type']}:#{a['name']}" }
|
|
152
|
+
current_keys = current.map { |a| "#{a['type']}:#{a['name']}" }
|
|
153
|
+
|
|
154
|
+
issues = (current_keys - stored_keys).map { |k| "+ missing association: #{k}" }
|
|
155
|
+
(stored_keys - current_keys).each { |k| issues << "- extra association: #{k} (no longer defined)" }
|
|
156
|
+
issues
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def diff_named_list(stored, current, label, key)
|
|
160
|
+
stored ||= []
|
|
161
|
+
stored_names = stored.map { |i| i[key] }.compact
|
|
162
|
+
current_names = current.map { |i| i[key] }.compact
|
|
163
|
+
|
|
164
|
+
issues = (current_names - stored_names).map { |n| "+ missing #{label}: #{n}" }
|
|
165
|
+
(stored_names - current_names).each { |n| issues << "- extra #{label}: #{n} (no longer in schema)" }
|
|
166
|
+
issues
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
data/lib/sidenotes/version.rb
CHANGED
data/lib/sidenotes.rb
CHANGED
|
@@ -5,6 +5,7 @@ require_relative 'sidenotes/configuration'
|
|
|
5
5
|
require_relative 'sidenotes/model_inspector'
|
|
6
6
|
require_relative 'sidenotes/formatter'
|
|
7
7
|
require_relative 'sidenotes/generator'
|
|
8
|
+
require_relative 'sidenotes/verifier'
|
|
8
9
|
|
|
9
10
|
module Sidenotes
|
|
10
11
|
class Error < StandardError; end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: sidenotes
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Wes Mason
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-
|
|
11
|
+
date: 2026-05-01 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: activerecord
|
|
@@ -74,6 +74,7 @@ files:
|
|
|
74
74
|
- lib/sidenotes/generator.rb
|
|
75
75
|
- lib/sidenotes/model_inspector.rb
|
|
76
76
|
- lib/sidenotes/railtie.rb
|
|
77
|
+
- lib/sidenotes/verifier.rb
|
|
77
78
|
- lib/sidenotes/version.rb
|
|
78
79
|
homepage: https://github.com/1stvamp/sidenotes-ruby
|
|
79
80
|
licenses:
|