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 +4 -4
- data/CHANGELOG.md +9 -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 +46 -0
- data/lib/sidenotes/verifier.rb +138 -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: 24381f7028cc342a69f1b713f2604ae0a0bdc829d675f8f866241550e13b4440
|
|
4
|
+
data.tar.gz: 384a71a3b65bf767fd3634a84e03125329cb72fb5638dd29a9160a3f157e9bca
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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)
|
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,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
|
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.
|
|
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-
|
|
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:
|