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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8ae9eac3b04b22f97ed8449628befa33a88bfa94ac1e3533a62290ffffb1a32
4
- data.tar.gz: 81616ba19442a2b9dbae04a494d092cd93f998aaf5fb2a105f75a0fbfb059b16
3
+ metadata.gz: 49cfca99965003c2f6fbff02ac2d9a930671bec5bb36f0cafecbb03e8c2e47f1
4
+ data.tar.gz: 505db15a10c3df2c3af8d3d3425d1cb3ea4580faf22a24fe0ba10a48dd0c432d
5
5
  SHA512:
6
- metadata.gz: 668ef3a9a5bbdb367fce80df121953c503f8ac4f948ba246eb1cf297ad620adba89e8e9e97a9a4bf8bb8d518f052fd4a6cbb79090099a1e8541f2ab19cce6a14
7
- data.tar.gz: 5bad7f9f8f5f6673938f93e85711d7782b0abe180c49e4f7ba3d14047f729bd868b7e095ee1263c663d83ff728d6eed0f936b933cefd1ea03145c1bcf58f6ebb
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)
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sidenotes
4
- VERSION = '0.1.2'
4
+ VERSION = '0.2.1'
5
5
  end
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.2
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-04-09 00:00:00.000000000 Z
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: