sidenotes 0.1.2 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b8ae9eac3b04b22f97ed8449628befa33a88bfa94ac1e3533a62290ffffb1a32
4
- data.tar.gz: 81616ba19442a2b9dbae04a494d092cd93f998aaf5fb2a105f75a0fbfb059b16
3
+ metadata.gz: 24381f7028cc342a69f1b713f2604ae0a0bdc829d675f8f866241550e13b4440
4
+ data.tar.gz: 384a71a3b65bf767fd3634a84e03125329cb72fb5638dd29a9160a3f157e9bca
5
5
  SHA512:
6
- metadata.gz: 668ef3a9a5bbdb367fce80df121953c503f8ac4f948ba246eb1cf297ad620adba89e8e9e97a9a4bf8bb8d518f052fd4a6cbb79090099a1e8541f2ab19cce6a14
7
- data.tar.gz: 5bad7f9f8f5f6673938f93e85711d7782b0abe180c49e4f7ba3d14047f729bd868b7e095ee1263c663d83ff728d6eed0f936b933cefd1ea03145c1bcf58f6ebb
6
+ metadata.gz: 23c955f99d8e8c4f46c221cc96332907ba20dd6492271eb176ce2ca07a40842a460720b17f8294acdc929980e59b014706acf4ac8363c405708df4864d07c368
7
+ data.tar.gz: 8a37de79dc70a7e941845485b2435bbabf9580fc07caaa6db9c86a4f55204fd3d84cbc01c23be1196c87715a83c52b6daae899e6c41061bd953ac6a8e6063ecb
data/CHANGELOG.md CHANGED
@@ -5,6 +5,15 @@ 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.0] - 2026-04-29
9
+
10
+ ### Added
11
+
12
+ - `sidenotes:verify` rake task that compares sidecar files against the database schema, reports drift, and exits non-zero on mismatch (suitable for CI)
13
+ - `Sidenotes::Verifier` class for programmatic drift detection
14
+ - `auto_generate_after_migrate` configuration option that hooks into `db:migrate`, `db:migrate:up`, `db:migrate:down`, and `db:rollback` to regenerate annotations automatically
15
+ - `SIDENOTES_AUTO_GENERATE` env var as a zero-config alternative to the configuration flag
16
+
8
17
  ## [0.1.0] - 2026-04-08
9
18
 
10
19
  ### 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). Exits with status `1` if any drift is 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,42 @@ 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
+ verifier.drifts.each do |drift|
72
+ puts " #{File.basename(drift.sidecar_path)} - DRIFT:"
73
+ drift.issues.each { |issue| puts " #{issue}" }
74
+ puts
75
+ end
76
+
77
+ verifier.missing_sidecars.each do |name|
78
+ puts " #{name} - MISSING (model exists, no sidecar file)"
79
+ end
80
+
81
+ if verifier.drift?
82
+ puts "#{verifier.issue_count} issue(s) found. Run `rake sidenotes:generate` to update."
83
+ else
84
+ puts 'All annotations are up to date.'
85
+ end
86
+ end
87
+
88
+ def self.install_migrate_hooks
89
+ MIGRATE_TASKS.each do |task_name|
90
+ next unless Rake::Task.task_defined?(task_name)
91
+
92
+ Rake::Task[task_name].enhance do
93
+ Rake::Task['sidenotes:generate'].invoke if Rake::Task.task_defined?('sidenotes:generate')
94
+ end
95
+ end
96
+ end
51
97
  end
52
98
  end
@@ -0,0 +1,138 @@
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
11
+
12
+ def initialize
13
+ @drifts = []
14
+ @missing_sidecars = []
15
+ @checked_count = 0
16
+ end
17
+
18
+ def verify_all
19
+ generator = Generator.new
20
+ generator.discover_models.each { |model| verify_model(model) }
21
+ self
22
+ end
23
+
24
+ def verify_model(model)
25
+ inspector = ModelInspector.new(model)
26
+ return unless inspector.inspectable?
27
+
28
+ @checked_count += 1
29
+ sidecar = sidecar_path_for(model)
30
+
31
+ unless File.exist?(sidecar)
32
+ @missing_sidecars << model.name
33
+ return
34
+ end
35
+
36
+ stored = parse_sidecar(sidecar, model.name)
37
+ current = inspector.inspect_model
38
+ issues = compare(stored, current)
39
+
40
+ return if issues.empty?
41
+
42
+ @drifts << Drift.new(model_name: model.name, sidecar_path: sidecar, issues: issues)
43
+ end
44
+
45
+ def drift?
46
+ drifts.any? || missing_sidecars.any?
47
+ end
48
+
49
+ def issue_count
50
+ drifts.sum { |d| d.issues.size } + missing_sidecars.size
51
+ end
52
+
53
+ private
54
+
55
+ def sidecar_path_for(model)
56
+ config = Sidenotes.configuration
57
+ File.join(config.output_directory, "#{model.name.underscore}.#{config.file_extension}")
58
+ end
59
+
60
+ def parse_sidecar(path, model_name)
61
+ content = File.read(path)
62
+ parsed = case Sidenotes.configuration.format
63
+ when :yaml then YAML.safe_load(strip_yaml_header(content), permitted_classes: [Symbol])
64
+ when :json then JSON.parse(content)
65
+ end
66
+ parsed[model_name] || {}
67
+ end
68
+
69
+ def strip_yaml_header(content)
70
+ content.lines.reject { |l| l.start_with?('#') }.join
71
+ end
72
+
73
+ def compare(stored, current)
74
+ issues = []
75
+ issues.concat(diff_columns(stored['columns'], current['columns'])) if current.key?('columns')
76
+ issues.concat(diff_indexes(stored['indexes'], current['indexes'])) if current.key?('indexes')
77
+ if current.key?('foreign_keys')
78
+ issues.concat(diff_named_list(stored['foreign_keys'], current['foreign_keys'], 'foreign_key',
79
+ 'name'))
80
+ end
81
+ issues.concat(diff_associations(stored['associations'], current['associations'])) if current.key?('associations')
82
+ issues
83
+ end
84
+
85
+ def diff_columns(stored, current)
86
+ stored_by_name = (stored || []).to_h { |c| [c['name'], c] }
87
+ current_by_name = current.to_h { |c| [c['name'], c] }
88
+
89
+ added = (current_by_name.keys - stored_by_name.keys).map do |name|
90
+ "+ missing column: #{name} (#{current_by_name[name]['type']})"
91
+ end
92
+ removed = (stored_by_name.keys - current_by_name.keys).map do |name|
93
+ "- extra column: #{name} (no longer in schema)"
94
+ end
95
+ changed = column_type_changes(stored_by_name, current_by_name)
96
+
97
+ added + removed + changed
98
+ end
99
+
100
+ def column_type_changes(stored_by_name, current_by_name)
101
+ (stored_by_name.keys & current_by_name.keys).filter_map do |name|
102
+ s = stored_by_name[name]
103
+ c = current_by_name[name]
104
+ "~ column changed: #{name} (#{s['type']} -> #{c['type']})" if s['type'] != c['type']
105
+ end
106
+ end
107
+
108
+ def diff_indexes(stored, current)
109
+ stored ||= []
110
+ stored_names = stored.map { |i| i['name'] }
111
+ current_names = current.map { |i| i['name'] }
112
+
113
+ issues = (current_names - stored_names).map { |n| "+ missing index: #{n}" }
114
+ (stored_names - current_names).each { |n| issues << "- extra index: #{n} (no longer in schema)" }
115
+ issues
116
+ end
117
+
118
+ def diff_associations(stored, current)
119
+ stored ||= []
120
+ stored_keys = stored.map { |a| "#{a['type']}:#{a['name']}" }
121
+ current_keys = current.map { |a| "#{a['type']}:#{a['name']}" }
122
+
123
+ issues = (current_keys - stored_keys).map { |k| "+ missing association: #{k}" }
124
+ (stored_keys - current_keys).each { |k| issues << "- extra association: #{k} (no longer defined)" }
125
+ issues
126
+ end
127
+
128
+ def diff_named_list(stored, current, label, key)
129
+ stored ||= []
130
+ stored_names = stored.map { |i| i[key] }.compact
131
+ current_names = current.map { |i| i[key] }.compact
132
+
133
+ issues = (current_names - stored_names).map { |n| "+ missing #{label}: #{n}" }
134
+ (stored_names - current_names).each { |n| issues << "- extra #{label}: #{n} (no longer in schema)" }
135
+ issues
136
+ end
137
+ end
138
+ 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.0'
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.0
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-04-29 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: