woods 1.4.1 → 1.6.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +208 -0
  3. data/CONTRIBUTING.md +63 -1
  4. data/README.md +35 -2
  5. data/lib/tasks/woods.rake +52 -2
  6. data/lib/woods/atomic_file.rb +43 -0
  7. data/lib/woods/cache/cache_middleware.rb +18 -1
  8. data/lib/woods/console/embedded_executor.rb +20 -2
  9. data/lib/woods/console/eval_guard.rb +8 -2
  10. data/lib/woods/console/sql_noise_stripper.rb +113 -0
  11. data/lib/woods/console/sql_table_scanner.rb +22 -9
  12. data/lib/woods/console/sql_validator.rb +1 -2
  13. data/lib/woods/coordination/pipeline_lock.rb +112 -7
  14. data/lib/woods/dependency_graph.rb +38 -1
  15. data/lib/woods/embedding/text_preparer.rb +6 -1
  16. data/lib/woods/export/unit_facts.rb +80 -0
  17. data/lib/woods/extractor.rb +152 -31
  18. data/lib/woods/extractors/concern_extractor.rb +3 -5
  19. data/lib/woods/extractors/configuration_extractor.rb +3 -5
  20. data/lib/woods/extractors/controller_extractor.rb +1 -1
  21. data/lib/woods/extractors/database_view_extractor.rb +1 -1
  22. data/lib/woods/extractors/decorator_extractor.rb +3 -5
  23. data/lib/woods/extractors/event_extractor.rb +2 -2
  24. data/lib/woods/extractors/factory_extractor.rb +2 -4
  25. data/lib/woods/extractors/graphql_extractor.rb +1 -1
  26. data/lib/woods/extractors/i18n_extractor.rb +6 -4
  27. data/lib/woods/extractors/job_extractor.rb +4 -6
  28. data/lib/woods/extractors/lib_extractor.rb +1 -1
  29. data/lib/woods/extractors/mailer_extractor.rb +1 -1
  30. data/lib/woods/extractors/manager_extractor.rb +3 -5
  31. data/lib/woods/extractors/migration_extractor.rb +1 -1
  32. data/lib/woods/extractors/model_extractor.rb +26 -10
  33. data/lib/woods/extractors/phlex_extractor.rb +1 -1
  34. data/lib/woods/extractors/policy_extractor.rb +3 -5
  35. data/lib/woods/extractors/poro_extractor.rb +1 -1
  36. data/lib/woods/extractors/pundit_extractor.rb +3 -5
  37. data/lib/woods/extractors/rake_task_extractor.rb +2 -4
  38. data/lib/woods/extractors/serializer_extractor.rb +4 -6
  39. data/lib/woods/extractors/service_extractor.rb +3 -5
  40. data/lib/woods/extractors/shared_dependency_scanner.rb +88 -16
  41. data/lib/woods/extractors/shared_utility_methods.rb +14 -0
  42. data/lib/woods/extractors/state_machine_extractor.rb +2 -4
  43. data/lib/woods/extractors/validator_extractor.rb +3 -5
  44. data/lib/woods/extractors/view_component_extractor.rb +1 -1
  45. data/lib/woods/filename_utils.rb +31 -2
  46. data/lib/woods/flow_assembler.rb +45 -28
  47. data/lib/woods/flow_precomputer.rb +2 -1
  48. data/lib/woods/git_provenance.rb +121 -0
  49. data/lib/woods/index_artifact.rb +8 -4
  50. data/lib/woods/mcp/bootstrapper.rb +6 -5
  51. data/lib/woods/mcp/config_resolver.rb +42 -21
  52. data/lib/woods/mcp/errors.rb +5 -3
  53. data/lib/woods/mcp/server.rb +68 -15
  54. data/lib/woods/mcp/version_aware_tool_dispatch.rb +47 -0
  55. data/lib/woods/model_name_cache.rb +6 -1
  56. data/lib/woods/notion/client.rb +4 -1
  57. data/lib/woods/notion/exporter.rb +7 -1
  58. data/lib/woods/obsidian/errors.rb +11 -0
  59. data/lib/woods/obsidian/name_mapper.rb +132 -0
  60. data/lib/woods/obsidian/note_builder.rb +260 -0
  61. data/lib/woods/obsidian/vault_assets.rb +104 -0
  62. data/lib/woods/obsidian/vault_exporter.rb +412 -0
  63. data/lib/woods/operator/error_escalator.rb +15 -4
  64. data/lib/woods/resilience/circuit_breaker.rb +98 -24
  65. data/lib/woods/resolved_config.rb +4 -1
  66. data/lib/woods/retrieval/ranker.rb +45 -7
  67. data/lib/woods/retry_after.rb +36 -0
  68. data/lib/woods/temporal/json_snapshot_store.rb +4 -1
  69. data/lib/woods/temporal/snapshot_store.rb +4 -1
  70. data/lib/woods/unblocked/client.rb +4 -1
  71. data/lib/woods/unblocked/document_builder.rb +21 -27
  72. data/lib/woods/update_check.rb +190 -0
  73. data/lib/woods/version.rb +1 -1
  74. data/lib/woods.rb +24 -0
  75. metadata +15 -4
@@ -82,6 +82,119 @@ module Woods
82
82
  pattern = dialect == :mysql ? SINGLE_QUOTED_MYSQL : SINGLE_QUOTED_POSTGRES
83
83
  out.gsub(pattern, "''")
84
84
  end
85
+
86
+ # Strip BOTH comments and string literals in a single left-to-right
87
+ # pass, so a comment marker inside a literal and a quote inside a
88
+ # comment are each protected by whichever construct opens first.
89
+ #
90
+ # Running {.strip_comments} then {.strip_literals} (or vice versa) is
91
+ # unsafe: `SELECT '-- ' FROM blocked` has its real `FROM blocked`
92
+ # swallowed as a line comment (the `--` sits inside a string literal),
93
+ # letting a blocked table slip past {TableGate}; the reverse order
94
+ # mis-handles an apostrophe inside a `--` comment. Only a combined scan
95
+ # that tracks literal/comment state correctly resolves both. This
96
+ # scanner backs security checks (SqlValidator, TableGate) so it must
97
+ # never under-detect: an unterminated literal is treated as an ordinary
98
+ # character rather than swallowing the rest of the statement.
99
+ #
100
+ # @param sql [String] the SQL string to process
101
+ # @param dialect [Symbol] `:postgres` (default) or `:mysql` — controls
102
+ # single-quote escape rules (see {.strip_literals}).
103
+ # @return [String] a new string with comments removed and every string
104
+ # literal replaced by `''`
105
+ # @raise [ArgumentError] if an unsupported dialect is provided
106
+ def self.strip_noise(sql, dialect: :postgres) # rubocop:disable Metrics/MethodLength,Metrics/CyclomaticComplexity,Metrics/PerceivedComplexity,Metrics/AbcSize
107
+ unless SUPPORTED_DIALECTS.include?(dialect)
108
+ raise ArgumentError, "Unknown dialect #{dialect.inspect}. Supported: #{SUPPORTED_DIALECTS.inspect}"
109
+ end
110
+
111
+ mysql = dialect == :mysql
112
+ out = +''
113
+ i = 0
114
+ len = sql.length
115
+
116
+ while i < len
117
+ ch = sql[i]
118
+
119
+ if ch == "'"
120
+ close = single_quote_end(sql, i, mysql: mysql)
121
+ if close
122
+ out << "''"
123
+ i = close
124
+ else
125
+ # Unterminated literal — never under-detect; keep the char.
126
+ out << ch
127
+ i += 1
128
+ end
129
+ elsif ch == '$' && (tag = dollar_tag_at(sql, i))
130
+ close = sql.index(tag, i + tag.length)
131
+ if close
132
+ out << "''"
133
+ i = close + tag.length
134
+ else
135
+ out << ch
136
+ i += 1
137
+ end
138
+ elsif ch == '-' && sql[i + 1] == '-'
139
+ nl = sql.index("\n", i)
140
+ i = nl || len
141
+ elsif ch == '/' && sql[i + 1] == '*'
142
+ close = sql.index('*/', i + 2)
143
+ if close
144
+ i = close + 2
145
+ else
146
+ # Unterminated block comment: never under-detect. Leave it in
147
+ # place (over-detection is safe; the old regex also required a
148
+ # closing */ and left an unterminated /* untouched).
149
+ out << ch
150
+ i += 1
151
+ end
152
+ else
153
+ out << ch
154
+ i += 1
155
+ end
156
+ end
157
+
158
+ out
159
+ end
160
+
161
+ # Regexp matching a PostgreSQL dollar-quote opening tag (`$$` or
162
+ # `$tag$`) at the start of the given slice.
163
+ DOLLAR_TAG = /\A\$\w*\$/
164
+ private_constant :DOLLAR_TAG
165
+
166
+ # Return the dollar-quote tag opening at +index+, or nil.
167
+ #
168
+ # @api private
169
+ def self.dollar_tag_at(sql, index)
170
+ m = DOLLAR_TAG.match(sql[index..])
171
+ m && m[0]
172
+ end
173
+ private_class_method :dollar_tag_at
174
+
175
+ # Return the index just past the closing quote of the single-quoted
176
+ # literal that opens at +start+, honoring `''` (both dialects) and `\'`
177
+ # (MySQL only) escapes. Returns nil when the literal is unterminated.
178
+ #
179
+ # @api private
180
+ def self.single_quote_end(sql, start, mysql:)
181
+ i = start + 1
182
+ len = sql.length
183
+ while i < len
184
+ c = sql[i]
185
+ if mysql && c == '\\'
186
+ i += 2
187
+ elsif c == "'"
188
+ return i + 1 unless sql[i + 1] == "'" # closing quote
189
+
190
+ i += 2 # doubled-quote escape — literal continues
191
+ else
192
+ i += 1
193
+ end
194
+ end
195
+ nil
196
+ end
197
+ private_class_method :single_quote_end
85
198
  end
86
199
  end
87
200
  end
@@ -109,23 +109,36 @@ module Woods
109
109
  # stripped before scanning. Both JOIN-style and ANSI-89 comma-join syntax
110
110
  # are handled.
111
111
  #
112
+ # Literals are stripped under BOTH supported dialects and the scans
113
+ # unioned. This scanner backs TableGate, so it may over-detect but must
114
+ # never under-detect: stripping with the wrong dialect's escape rules
115
+ # can swallow a real FROM clause — e.g. MySQL's `\'` escape applied on
116
+ # a PostgreSQL host (where backslash is literal under
117
+ # standard_conforming_strings) folds `'x\' FROM blocked WHERE y = '`
118
+ # into one literal, hiding `blocked` from the gate while PostgreSQL
119
+ # genuinely reads that table.
120
+ #
112
121
  # @param sql [String, nil] the SQL string to scan
113
- # @return [Array<String>] identifiers in the order they were encountered;
114
- # may contain duplicates if the same table is referenced multiple times
122
+ # @return [Array<String>] identifiers in first-encounter order, deduplicated
115
123
  def self.identifiers_in(sql)
116
124
  return [] if sql.nil? || sql.empty?
117
125
 
118
- stripped = strip_noise(sql)
119
126
  results = []
120
- collect_join_identifiers(stripped, results)
121
- collect_from_identifiers(stripped, results)
122
- results
127
+ %i[postgres mysql].each do |dialect|
128
+ stripped = strip_noise(sql, dialect: dialect)
129
+ collect_join_identifiers(stripped, results)
130
+ collect_from_identifiers(stripped, results)
131
+ end
132
+ results.uniq
123
133
  end
124
134
 
125
135
  # @api private
126
- def self.strip_noise(sql)
127
- out = SqlNoiseStripper.strip_comments(sql)
128
- SqlNoiseStripper.strip_literals(out, dialect: :mysql)
136
+ # Comments and literals must be stripped in a single combined pass —
137
+ # stripping them separately lets a comment marker inside a literal
138
+ # (`'-- '`) hide a real FROM clause from the gate. See
139
+ # {SqlNoiseStripper.strip_noise}.
140
+ def self.strip_noise(sql, dialect:)
141
+ SqlNoiseStripper.strip_noise(sql, dialect: dialect)
129
142
  end
130
143
  private_class_method :strip_noise
131
144
 
@@ -152,8 +152,7 @@ module Woods
152
152
  # @param sql [String]
153
153
  # @return [Boolean]
154
154
  def contains_multiple_statements?(sql)
155
- stripped = SqlNoiseStripper.strip_comments(sql)
156
- stripped = SqlNoiseStripper.strip_literals(stripped)
155
+ stripped = SqlNoiseStripper.strip_noise(sql)
157
156
  stripped.include?(';')
158
157
  end
159
158
 
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'fileutils'
4
4
  require 'json'
5
+ require 'securerandom'
5
6
 
6
7
  module Woods
7
8
  module Coordination
@@ -38,12 +39,13 @@ module Woods
38
39
  def acquire
39
40
  FileUtils.mkdir_p(@lock_dir)
40
41
 
41
- # Check for stale lock first (separate from atomic creation)
42
42
  if File.exist?(@lock_path)
43
43
  return false unless stale?
44
-
45
- # Remove stale lock
46
- FileUtils.rm_f(@lock_path)
44
+ # Retire the stale lock atomically. A bare rm_f + create here is
45
+ # a TOCTOU race: two processes passing the stale check together
46
+ # could each delete-and-create, the second deleting the first's
47
+ # FRESH lock — both would then "hold" it.
48
+ return false unless retire_stale_lock
47
49
  end
48
50
 
49
51
  # Atomic lock creation: File::EXCL ensures this fails if file already exists
@@ -58,10 +60,37 @@ module Woods
58
60
 
59
61
  # Release the lock.
60
62
  #
63
+ # Deletes the lock file only if it still carries this instance's
64
+ # token — a run that outlived stale_timeout may have been
65
+ # legitimately taken over, and deleting unconditionally would drop
66
+ # the new holder's lock.
67
+ #
61
68
  # @return [void]
62
69
  def release
63
- FileUtils.rm_f(@lock_path) if @held
70
+ return unless @held
71
+
72
+ # Clear @held up front so no later failure can leave this instance
73
+ # believing it still holds the lock.
64
74
  @held = false
75
+
76
+ # Rename first, then inspect: a plain read-then-unlink is a TOCTOU —
77
+ # after we read our own token a takeover could replace the file, and
78
+ # our unlink would then delete the NEW holder's lock. Renaming
79
+ # atomically captures whatever is at the path.
80
+ graveyard = "#{@lock_path}.release.#{Process.pid}.#{SecureRandom.hex(4)}"
81
+ begin
82
+ File.rename(@lock_path, graveyard)
83
+ rescue Errno::ENOENT
84
+ return # already gone
85
+ end
86
+
87
+ if own_lock?(graveyard)
88
+ FileUtils.rm_f(graveyard)
89
+ else
90
+ # We were legitimately taken over — put the successor's lock back
91
+ # without clobbering a still-newer holder (see {#restore_lock}).
92
+ restore_lock(graveyard)
93
+ end
65
94
  end
66
95
 
67
96
  # Execute a block while holding the lock.
@@ -100,9 +129,85 @@ module Woods
100
129
  true
101
130
  end
102
131
 
103
- # @return [String] Lock file content (JSON with PID and timestamp)
132
+ # Atomically retire a stale lock file via rename. Rename is atomic on
133
+ # POSIX: of any processes racing to take over the same stale lock,
134
+ # exactly one rename succeeds; the losers get ENOENT and back off.
135
+ # Winning the rename does NOT guarantee winning the lock — another
136
+ # process may O_EXCL-create between our rename and our create, which
137
+ # the caller's EEXIST rescue handles.
138
+ #
139
+ # Rename alone is not enough, though: a competitor that already passed
140
+ # `stale?` on the SAME original file may have retired it and created a
141
+ # FRESH lock before we run. Our rename would then move that fresh lock
142
+ # aside — and both processes would "hold" the lock. So after winning
143
+ # the rename we re-check the retired file's age; if it turns out to be
144
+ # fresh (someone beat us to the takeover), we put it back and lose the
145
+ # race instead of clobbering a live holder.
146
+ #
147
+ # @return [Boolean] true if this process retired a genuinely stale lock
148
+ def retire_stale_lock
149
+ graveyard = "#{@lock_path}.stale.#{Process.pid}.#{SecureRandom.hex(4)}"
150
+ File.rename(@lock_path, graveyard)
151
+
152
+ unless stale_file?(graveyard)
153
+ # We grabbed a lock that is no longer stale — a competitor already
154
+ # took over. Restore it (without clobbering a still-newer holder)
155
+ # and back off.
156
+ restore_lock(graveyard)
157
+ return false
158
+ end
159
+
160
+ FileUtils.rm_f(graveyard)
161
+ true
162
+ rescue Errno::ENOENT
163
+ false
164
+ end
165
+
166
+ # Whether the lock file at +path+ carries this instance's token.
167
+ #
168
+ # @param path [String]
169
+ # @return [Boolean] true when the token matches, or the file is corrupt
170
+ # (an unparseable lock we already renamed aside is treated as ours to
171
+ # discard rather than restore).
172
+ def own_lock?(path)
173
+ JSON.parse(File.read(path))['token'] == @token
174
+ rescue JSON::ParserError
175
+ true
176
+ end
177
+
178
+ # Put a lock file we renamed aside back at @lock_path WITHOUT clobbering
179
+ # a lock another process may have O_EXCL-created in the meantime.
180
+ # `File.link` is atomic and fails with EEXIST if @lock_path already
181
+ # exists, so a newer holder always wins; the aside copy is discarded
182
+ # either way. A plain `File.rename` back would overwrite that newer
183
+ # holder's lock — reintroducing a double-hold.
184
+ #
185
+ # @param graveyard [String] path of the renamed-aside lock file
186
+ # @return [void]
187
+ def restore_lock(graveyard)
188
+ File.link(graveyard, @lock_path)
189
+ rescue Errno::EEXIST
190
+ # A newer holder already claimed the path — our copy is obsolete.
191
+ nil
192
+ ensure
193
+ FileUtils.rm_f(graveyard)
194
+ end
195
+
196
+ # Whether the file at +path+ is older than the stale timeout.
197
+ #
198
+ # @param path [String]
199
+ # @return [Boolean]
200
+ def stale_file?(path)
201
+ Time.now - File.mtime(path) > @stale_timeout
202
+ rescue Errno::ENOENT
203
+ false
204
+ end
205
+
206
+ # @return [String] Lock file content (JSON with PID, timestamp, and
207
+ # an ownership token release verifies before deleting)
104
208
  def lock_content
105
- JSON.generate(pid: Process.pid, locked_at: Time.now.iso8601, name: @name)
209
+ @token = SecureRandom.hex(8)
210
+ JSON.generate(pid: Process.pid, locked_at: Time.now.iso8601, name: @name, token: @token)
106
211
  end
107
212
  end
108
213
  end
@@ -31,12 +31,20 @@ module Woods
31
31
  @to_h = nil
32
32
  end
33
33
 
34
- # Register a unit in the graph
34
+ # Register a unit in the graph.
35
+ #
36
+ # Re-registering an identifier (incremental extraction registers into a
37
+ # graph loaded from disk) first removes the previous registration's
38
+ # reverse edges, file-map entry, and type-index entry — otherwise stale
39
+ # dependents accumulate across incremental runs and get persisted back
40
+ # to dependency_graph.json.
35
41
  #
36
42
  # @param unit [ExtractedUnit] The unit to register
37
43
  def register(unit)
38
44
  @to_h = nil
39
45
 
46
+ unregister(unit.identifier) if @nodes.key?(unit.identifier)
47
+
40
48
  @nodes[unit.identifier] = {
41
49
  type: unit.type,
42
50
  file_path: unit.file_path,
@@ -56,6 +64,35 @@ module Woods
56
64
  end
57
65
  end
58
66
 
67
+ # Remove an identifier's registration side effects: its contribution to
68
+ # the reverse indexes (derived from its recorded forward edges), its
69
+ # file-map entry, and its type-index entry. Forward node/edge data is
70
+ # overwritten by the caller (register), so it is not cleared here.
71
+ #
72
+ # @param identifier [String] Previously-registered unit identifier
73
+ # @return [void]
74
+ def unregister(identifier)
75
+ (@edges[identifier] || []).each do |edge|
76
+ if (set = @reverse[edge[:target]])
77
+ set.delete(identifier)
78
+ @reverse.delete(edge[:target]) if set.empty?
79
+ end
80
+
81
+ via_key = [edge[:target], edge[:via]]
82
+ next unless (set = @reverse_via[via_key])
83
+
84
+ set.delete(identifier)
85
+ @reverse_via.delete(via_key) if set.empty?
86
+ end
87
+
88
+ old_node = @nodes[identifier]
89
+ return unless old_node
90
+
91
+ old_path = old_node[:file_path]
92
+ @file_map.delete(old_path) if old_path && @file_map[old_path] == identifier
93
+ @type_index[old_node[:type]]&.delete(identifier)
94
+ end
95
+
59
96
  # Find all units affected by changes to given files
60
97
  # Uses BFS to find transitive dependents
61
98
  #
@@ -95,7 +95,12 @@ module Woods
95
95
  def append_dependency_line(lines, dependencies)
96
96
  return unless dependencies&.any?
97
97
 
98
- dep_names = dependencies.map { |d| d[:target] }.compact.first(10)
98
+ # Dependency hashes arrive symbol-keyed from the extractor's
99
+ # in-memory units but string-keyed from the indexer (Indexer#build_unit
100
+ # reads JSON and does not symbolize dependency keys, unlike chunks).
101
+ # Read both forms or the whole "dependencies:" prefix silently
102
+ # vanishes from every embedded document on the indexing path.
103
+ dep_names = dependencies.filter_map { |d| d[:target] || d['target'] }.first(10)
99
104
  lines << "dependencies: #{dep_names.join(', ')}" if dep_names.any?
100
105
  end
101
106
 
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ # Shared building blocks for the export formatters (Unblocked DocumentBuilder,
5
+ # Obsidian NoteBuilder).
6
+ module Export
7
+ # Format-agnostic extraction of facts from a unit's metadata.
8
+ #
9
+ # Both the Unblocked and Obsidian exporters need the same *facts* about a
10
+ # unit — its associations grouped by macro, its schema highlights — but
11
+ # render them differently (plain text + GitHub URIs vs. wikilinks +
12
+ # frontmatter). This value object owns the one definition of "what a unit's
13
+ # metadata says"; each exporter is a thin formatter over it. The returned
14
+ # structures are deliberately raw (no truncation, no ordering imposed beyond
15
+ # the source array): formatting decisions — sort order, top-N caps — belong
16
+ # to each formatter so neither exporter's output is constrained by the other.
17
+ class UnitFacts
18
+ # @param unit [Hash] parsed unit JSON
19
+ def initialize(unit)
20
+ @meta = (unit && unit['metadata']) || {}
21
+ end
22
+
23
+ # Associations grouped by macro, each as a structured entry. Group keys are
24
+ # whatever macros are present (insertion order from the source array); the
25
+ # formatter picks the display order.
26
+ #
27
+ # @return [Hash{String=>Array<Hash>}] macro => [{ target:, dependent: }]
28
+ def associations_by_type
29
+ associations.group_by { |assoc| assoc['type'] }.transform_values do |items|
30
+ items.map { |a| { target: a['target'] || a['name'], dependent: a.dig('options', 'dependent') } }
31
+ end
32
+ end
33
+
34
+ # @return [Integer] total association count across all macros
35
+ def association_count
36
+ associations.size
37
+ end
38
+
39
+ # @return [Hash] { enums:, scopes:, concerns:, callbacks: } — raw, untruncated
40
+ def schema_highlights
41
+ { enums: enums, scopes: scopes, concerns: concerns, callbacks: callbacks }
42
+ end
43
+
44
+ def enums
45
+ @meta['enums'].is_a?(Hash) ? @meta['enums'] : {}
46
+ end
47
+
48
+ def scopes
49
+ @meta['scopes'].is_a?(Array) ? @meta['scopes'].filter_map { |s| s['name'] } : []
50
+ end
51
+
52
+ def concerns
53
+ @meta['inlined_concerns'].is_a?(Array) ? @meta['inlined_concerns'] : []
54
+ end
55
+
56
+ def callbacks
57
+ return [] unless @meta['callbacks'].is_a?(Array)
58
+
59
+ @meta['callbacks'].map { |cb| { type: cb['type'], filter: cb['filter'] } }
60
+ end
61
+
62
+ # Blast-radius bucket for a dependent count.
63
+ #
64
+ # @param dependent_count [Integer]
65
+ # @return [Symbol] :high (>50), :moderate (>20), or :none
66
+ def self.blast_radius(dependent_count)
67
+ return :high if dependent_count > 50
68
+ return :moderate if dependent_count > 20
69
+
70
+ :none
71
+ end
72
+
73
+ private
74
+
75
+ def associations
76
+ @meta['associations'].is_a?(Array) ? @meta['associations'] : []
77
+ end
78
+ end
79
+ end
80
+ end