@reinteractive/rails-insight 1.0.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.
- package/LICENSE +15 -0
- package/README.md +210 -0
- package/bin/railsinsight.js +128 -0
- package/package.json +62 -0
- package/src/core/blast-radius.js +496 -0
- package/src/core/constants.js +39 -0
- package/src/core/context-loader.js +227 -0
- package/src/core/drift-detector.js +168 -0
- package/src/core/formatter.js +197 -0
- package/src/core/graph.js +510 -0
- package/src/core/indexer.js +595 -0
- package/src/core/patterns/api.js +27 -0
- package/src/core/patterns/auth.js +25 -0
- package/src/core/patterns/authorization.js +24 -0
- package/src/core/patterns/caching.js +19 -0
- package/src/core/patterns/component.js +18 -0
- package/src/core/patterns/config.js +15 -0
- package/src/core/patterns/controller.js +42 -0
- package/src/core/patterns/email.js +20 -0
- package/src/core/patterns/factory.js +31 -0
- package/src/core/patterns/gemfile.js +9 -0
- package/src/core/patterns/helper.js +10 -0
- package/src/core/patterns/job.js +12 -0
- package/src/core/patterns/model.js +123 -0
- package/src/core/patterns/realtime.js +17 -0
- package/src/core/patterns/route.js +27 -0
- package/src/core/patterns/schema.js +25 -0
- package/src/core/patterns/stimulus.js +13 -0
- package/src/core/patterns/storage.js +16 -0
- package/src/core/patterns/uploader.js +16 -0
- package/src/core/patterns/view.js +20 -0
- package/src/core/patterns/worker.js +12 -0
- package/src/core/patterns.js +27 -0
- package/src/core/scanner.js +394 -0
- package/src/core/version-detector.js +295 -0
- package/src/extractors/api.js +284 -0
- package/src/extractors/auth.js +853 -0
- package/src/extractors/authorization.js +785 -0
- package/src/extractors/caching.js +84 -0
- package/src/extractors/component.js +221 -0
- package/src/extractors/config.js +81 -0
- package/src/extractors/controller.js +273 -0
- package/src/extractors/coverage-snapshot.js +296 -0
- package/src/extractors/email.js +123 -0
- package/src/extractors/factory-registry.js +225 -0
- package/src/extractors/gemfile.js +440 -0
- package/src/extractors/helper.js +55 -0
- package/src/extractors/jobs.js +122 -0
- package/src/extractors/model.js +506 -0
- package/src/extractors/realtime.js +102 -0
- package/src/extractors/routes.js +251 -0
- package/src/extractors/schema.js +178 -0
- package/src/extractors/stimulus.js +149 -0
- package/src/extractors/storage.js +100 -0
- package/src/extractors/test-conventions.js +340 -0
- package/src/extractors/tier2.js +417 -0
- package/src/extractors/tier3.js +84 -0
- package/src/extractors/uploader.js +138 -0
- package/src/extractors/views.js +131 -0
- package/src/extractors/worker.js +62 -0
- package/src/git/diff-parser.js +132 -0
- package/src/providers/interface.js +12 -0
- package/src/providers/local-fs.js +318 -0
- package/src/server.js +71 -0
- package/src/tools/blast-radius-tools.js +129 -0
- package/src/tools/free-tools.js +44 -0
- package/src/tools/handlers/get-controller.js +93 -0
- package/src/tools/handlers/get-coverage-gaps.js +100 -0
- package/src/tools/handlers/get-deep-analysis.js +294 -0
- package/src/tools/handlers/get-domain-clusters.js +113 -0
- package/src/tools/handlers/get-factory-registry.js +43 -0
- package/src/tools/handlers/get-full-index.js +28 -0
- package/src/tools/handlers/get-model.js +108 -0
- package/src/tools/handlers/get-overview.js +153 -0
- package/src/tools/handlers/get-routes.js +18 -0
- package/src/tools/handlers/get-schema.js +40 -0
- package/src/tools/handlers/get-subgraph.js +82 -0
- package/src/tools/handlers/get-test-conventions.js +18 -0
- package/src/tools/handlers/get-well-tested-examples.js +51 -0
- package/src/tools/handlers/helpers.js +115 -0
- package/src/tools/handlers/index-project.js +36 -0
- package/src/tools/handlers/search-patterns.js +104 -0
- package/src/tools/index.js +34 -0
- package/src/tools/pro-tools.js +13 -0
- package/src/utils/file-reader.js +20 -0
- package/src/utils/inflector.js +223 -0
- package/src/utils/ruby-parser.js +115 -0
- package/src/utils/spec-style-detector.js +26 -0
- package/src/utils/token-counter.js +46 -0
- package/src/utils/yaml-parser.js +135 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model Extractor (#1)
|
|
3
|
+
* Extracts all ActiveRecord model patterns from Ruby model files.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { MODEL_PATTERNS } from '../core/patterns.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract all model information from a single model file.
|
|
10
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
11
|
+
* @param {string} filePath
|
|
12
|
+
* @param {string} [className]
|
|
13
|
+
* @returns {object|null}
|
|
14
|
+
*/
|
|
15
|
+
export function extractModel(provider, filePath, className) {
|
|
16
|
+
const content = provider.readFile(filePath)
|
|
17
|
+
if (!content) return null
|
|
18
|
+
|
|
19
|
+
const isConcern =
|
|
20
|
+
/module\s+\w+/.test(content) &&
|
|
21
|
+
/extend\s+ActiveSupport::Concern/.test(content)
|
|
22
|
+
|
|
23
|
+
// Class/module declaration
|
|
24
|
+
let detectedClass = className || null
|
|
25
|
+
let superclass = null
|
|
26
|
+
const classMatch = content.match(MODEL_PATTERNS.classDeclaration)
|
|
27
|
+
if (classMatch) {
|
|
28
|
+
detectedClass = classMatch[1]
|
|
29
|
+
superclass = classMatch[2]
|
|
30
|
+
} else if (isConcern) {
|
|
31
|
+
const moduleMatch = content.match(/module\s+(\w+(?:::\w+)*)/)
|
|
32
|
+
if (moduleMatch) detectedClass = moduleMatch[1]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Concerns (include/extend)
|
|
36
|
+
const concerns = []
|
|
37
|
+
const extends_ = []
|
|
38
|
+
const includeRe = new RegExp(MODEL_PATTERNS.include.source, 'gm')
|
|
39
|
+
const extendRe = new RegExp(MODEL_PATTERNS.extend.source, 'gm')
|
|
40
|
+
let m
|
|
41
|
+
while ((m = includeRe.exec(content))) {
|
|
42
|
+
const mod = m[1]
|
|
43
|
+
if (
|
|
44
|
+
mod !== 'ActiveSupport::Concern' &&
|
|
45
|
+
mod !== 'Discard::Model' &&
|
|
46
|
+
mod !== 'AASM' &&
|
|
47
|
+
mod !== 'PgSearch::Model'
|
|
48
|
+
) {
|
|
49
|
+
concerns.push(mod)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
while ((m = extendRe.exec(content))) {
|
|
53
|
+
const mod = m[1]
|
|
54
|
+
if (mod !== 'ActiveSupport::Concern' && mod !== 'FriendlyId') {
|
|
55
|
+
extends_.push(mod)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Associations
|
|
60
|
+
const associations = []
|
|
61
|
+
const assocTypes = [
|
|
62
|
+
{ key: 'belongsTo', type: 'belongs_to' },
|
|
63
|
+
{ key: 'hasMany', type: 'has_many' },
|
|
64
|
+
{ key: 'hasOne', type: 'has_one' },
|
|
65
|
+
{ key: 'habtm', type: 'has_and_belongs_to_many' },
|
|
66
|
+
]
|
|
67
|
+
for (const { key, type } of assocTypes) {
|
|
68
|
+
const re = new RegExp(MODEL_PATTERNS[key].source, 'gm')
|
|
69
|
+
while ((m = re.exec(content))) {
|
|
70
|
+
const entry = { type, name: m[1], options: m[2] || null }
|
|
71
|
+
// Check for through
|
|
72
|
+
if (entry.options) {
|
|
73
|
+
const throughMatch = entry.options.match(MODEL_PATTERNS.through)
|
|
74
|
+
if (throughMatch) entry.through = throughMatch[1]
|
|
75
|
+
// Check for counter_cache
|
|
76
|
+
const ccMatch = entry.options.match(MODEL_PATTERNS.counterCache)
|
|
77
|
+
if (ccMatch) entry.counter_cache = true
|
|
78
|
+
// Check for polymorphic
|
|
79
|
+
if (MODEL_PATTERNS.polymorphic.test(entry.options))
|
|
80
|
+
entry.polymorphic = true
|
|
81
|
+
// Check for strict_loading
|
|
82
|
+
if (MODEL_PATTERNS.strictLoadingAssoc.test(entry.options))
|
|
83
|
+
entry.strict_loading = true
|
|
84
|
+
}
|
|
85
|
+
associations.push(entry)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Validations
|
|
90
|
+
const validations = []
|
|
91
|
+
const custom_validators = []
|
|
92
|
+
const validatesRe = new RegExp(MODEL_PATTERNS.validates.source, 'gm')
|
|
93
|
+
while ((m = validatesRe.exec(content))) {
|
|
94
|
+
const attrs = m[1].split(/,\s*:?/).map((a) => a.trim().replace(/^:/, ''))
|
|
95
|
+
validations.push({ attributes: attrs, rules: m[2] || '' })
|
|
96
|
+
}
|
|
97
|
+
const validateRe = new RegExp(MODEL_PATTERNS.validate.source, 'gm')
|
|
98
|
+
while ((m = validateRe.exec(content))) {
|
|
99
|
+
custom_validators.push(m[1])
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Scopes — names array (backward-compat) + scope_queries dict with bodies
|
|
103
|
+
const scopes = []
|
|
104
|
+
const scope_queries = {}
|
|
105
|
+
// Extended pattern: capture the body inside { } after ->
|
|
106
|
+
const scopeBodyRe =
|
|
107
|
+
/^\s*scope\s+:(\w+),\s*->\s*(?:\([^)]*\)\s*)?\{\s*([^}]+)\}/gm
|
|
108
|
+
const scopeSimpleRe = new RegExp(MODEL_PATTERNS.scope.source, 'gm')
|
|
109
|
+
const scopeNamesFound = new Set()
|
|
110
|
+
while ((m = scopeBodyRe.exec(content))) {
|
|
111
|
+
scopes.push(m[1])
|
|
112
|
+
scope_queries[m[1]] = m[2].trim().replace(/\s+/g, ' ')
|
|
113
|
+
scopeNamesFound.add(m[1])
|
|
114
|
+
}
|
|
115
|
+
// Fall back to name-only for scopes we couldn't extract a body from
|
|
116
|
+
while ((m = scopeSimpleRe.exec(content))) {
|
|
117
|
+
if (!scopeNamesFound.has(m[1])) scopes.push(m[1])
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Enums — values is always array of key names; value_map has int mapping
|
|
121
|
+
const enums = {}
|
|
122
|
+
// Modern hash syntax: enum :status, { key: 0, ... } (Rails 7+)
|
|
123
|
+
const enumModernHashRe = /^\s*enum\s+:(\w+),\s*\{([^}]+)\}/gm
|
|
124
|
+
while ((m = enumModernHashRe.exec(content))) {
|
|
125
|
+
const name = m[1]
|
|
126
|
+
const valStr = m[2]
|
|
127
|
+
const value_map = {}
|
|
128
|
+
const keys = []
|
|
129
|
+
const pairRe = /(\w+):\s*(\d+)/g
|
|
130
|
+
let pm
|
|
131
|
+
while ((pm = pairRe.exec(valStr))) {
|
|
132
|
+
value_map[pm[1]] = parseInt(pm[2], 10)
|
|
133
|
+
keys.push(pm[1])
|
|
134
|
+
}
|
|
135
|
+
if (keys.length > 0) {
|
|
136
|
+
enums[name] = { values: keys, value_map, syntax: 'hash' }
|
|
137
|
+
} else {
|
|
138
|
+
const symKeys =
|
|
139
|
+
valStr.match(/\w+/g)?.filter((v) => !/^\d+$/.test(v)) || []
|
|
140
|
+
enums[name] = { values: symKeys, syntax: 'hash' }
|
|
141
|
+
}
|
|
142
|
+
// Check for validate: true after the closing brace
|
|
143
|
+
const afterEnum = content.slice(
|
|
144
|
+
m.index + m[0].length,
|
|
145
|
+
m.index + m[0].length + 50,
|
|
146
|
+
)
|
|
147
|
+
if (/validate:\s*true/.test(m[0] + afterEnum)) {
|
|
148
|
+
enums[name].validate = true
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Legacy hash syntax: enum status: { draft: 0, ... } (Rails 4-6)
|
|
152
|
+
const enumLegacyHashRe = /^\s*enum\s+(\w+):\s*\{([^}]+)\}/gm
|
|
153
|
+
while ((m = enumLegacyHashRe.exec(content))) {
|
|
154
|
+
const name = m[1]
|
|
155
|
+
if (enums[name]) continue
|
|
156
|
+
const valStr = m[2]
|
|
157
|
+
const value_map = {}
|
|
158
|
+
const keys = []
|
|
159
|
+
const pairRe = /(\w+):\s*(\d+)/g
|
|
160
|
+
let pm
|
|
161
|
+
while ((pm = pairRe.exec(valStr))) {
|
|
162
|
+
value_map[pm[1]] = parseInt(pm[2], 10)
|
|
163
|
+
keys.push(pm[1])
|
|
164
|
+
}
|
|
165
|
+
if (keys.length > 0) {
|
|
166
|
+
enums[name] = { values: keys, value_map, syntax: 'legacy' }
|
|
167
|
+
} else {
|
|
168
|
+
const symKeys =
|
|
169
|
+
valStr.match(/\w+/g)?.filter((v) => !/^\d+$/.test(v)) || []
|
|
170
|
+
enums[name] = { values: symKeys, syntax: 'legacy' }
|
|
171
|
+
}
|
|
172
|
+
// Check for validate: true after the closing brace
|
|
173
|
+
const afterEnum = content.slice(
|
|
174
|
+
m.index + m[0].length,
|
|
175
|
+
m.index + m[0].length + 50,
|
|
176
|
+
)
|
|
177
|
+
if (/validate:\s*true/.test(m[0] + afterEnum)) {
|
|
178
|
+
enums[name].validate = true
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
// Array syntax: enum :role, [ :a, :b ] — only add if not already captured
|
|
182
|
+
const enumArrayPatterns = [
|
|
183
|
+
{ re: MODEL_PATTERNS.enumPositionalArray, syntax: 'positional_array' },
|
|
184
|
+
{ re: MODEL_PATTERNS.enumLegacyArray, syntax: 'legacy_array' },
|
|
185
|
+
]
|
|
186
|
+
for (const { re, syntax } of enumArrayPatterns) {
|
|
187
|
+
const gre = new RegExp(re.source, 'gm')
|
|
188
|
+
while ((m = gre.exec(content))) {
|
|
189
|
+
const name = m[1]
|
|
190
|
+
if (enums[name]) continue // already captured from hash syntax
|
|
191
|
+
const values = (m[2].match(/\w+/g) || []).filter((v) => !/^\d+$/.test(v))
|
|
192
|
+
enums[name] = { values, syntax }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Callbacks
|
|
197
|
+
const callbacks = []
|
|
198
|
+
const cbRe = new RegExp(MODEL_PATTERNS.callbackType.source, 'gm')
|
|
199
|
+
while ((m = cbRe.exec(content))) {
|
|
200
|
+
callbacks.push({ type: m[1], method: m[2], options: m[3] || null })
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Delegations
|
|
204
|
+
const delegations = []
|
|
205
|
+
const delRe = new RegExp(MODEL_PATTERNS.delegate.source, 'gm')
|
|
206
|
+
while ((m = delRe.exec(content))) {
|
|
207
|
+
delegations.push({ methods: m[1].trim(), to: m[2] })
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Encrypts
|
|
211
|
+
const encrypts = []
|
|
212
|
+
const encRe = new RegExp(MODEL_PATTERNS.encrypts.source, 'gm')
|
|
213
|
+
while ((m = encRe.exec(content))) {
|
|
214
|
+
const attrs = m[1].match(/:(\w+)/g)
|
|
215
|
+
if (attrs) encrypts.push(...attrs.map((a) => a.slice(1)))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Normalizes
|
|
219
|
+
const normalizes = []
|
|
220
|
+
const normRe = new RegExp(MODEL_PATTERNS.normalizes.source, 'gm')
|
|
221
|
+
while ((m = normRe.exec(content))) {
|
|
222
|
+
const fullDecl = m[1]
|
|
223
|
+
const attrs = fullDecl.match(/:(\w+)/g)?.map((a) => a.slice(1)) || []
|
|
224
|
+
const withMatch = fullDecl.match(
|
|
225
|
+
/with:\s*->\s*(?:\([^)]*\)\s*)?\{([^}]+)\}/,
|
|
226
|
+
)
|
|
227
|
+
const normExpression = withMatch ? withMatch[1].trim() : null
|
|
228
|
+
for (const attr of attrs) {
|
|
229
|
+
normalizes.push({ attribute: attr, expression: normExpression })
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Token generators
|
|
234
|
+
const token_generators = []
|
|
235
|
+
const tokenRe = new RegExp(MODEL_PATTERNS.generatesTokenFor.source, 'gm')
|
|
236
|
+
while ((m = tokenRe.exec(content))) {
|
|
237
|
+
token_generators.push(m[1])
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Secure password
|
|
241
|
+
const has_secure_password = MODEL_PATTERNS.hasSecurePassword.test(content)
|
|
242
|
+
|
|
243
|
+
// Attachments
|
|
244
|
+
const attachments = []
|
|
245
|
+
const attachPatterns = [
|
|
246
|
+
{ re: MODEL_PATTERNS.hasOneAttached, type: 'has_one_attached' },
|
|
247
|
+
{ re: MODEL_PATTERNS.hasManyAttached, type: 'has_many_attached' },
|
|
248
|
+
]
|
|
249
|
+
for (const { re, type } of attachPatterns) {
|
|
250
|
+
const gre = new RegExp(re.source, 'gm')
|
|
251
|
+
while ((m = gre.exec(content))) {
|
|
252
|
+
attachments.push({ type, name: m[1] })
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Rich text
|
|
257
|
+
const rich_text = []
|
|
258
|
+
const rtRe = new RegExp(MODEL_PATTERNS.hasRichText.source, 'gm')
|
|
259
|
+
while ((m = rtRe.exec(content))) {
|
|
260
|
+
rich_text.push(m[1])
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Store accessors
|
|
264
|
+
const store_accessors = {}
|
|
265
|
+
const storeRe = new RegExp(MODEL_PATTERNS.store.source, 'gm')
|
|
266
|
+
while ((m = storeRe.exec(content))) {
|
|
267
|
+
store_accessors[m[1]] = m[2].match(/:(\w+)/g)?.map((a) => a.slice(1)) || []
|
|
268
|
+
}
|
|
269
|
+
const saRe = new RegExp(MODEL_PATTERNS.storeAccessor.source, 'gm')
|
|
270
|
+
while ((m = saRe.exec(content))) {
|
|
271
|
+
const storeName = m[1]
|
|
272
|
+
const accessors = m[2].match(/:(\w+)/g)?.map((a) => a.slice(1)) || []
|
|
273
|
+
store_accessors[storeName] = [
|
|
274
|
+
...(store_accessors[storeName] || []),
|
|
275
|
+
...accessors,
|
|
276
|
+
]
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Table name override
|
|
280
|
+
const tableMatch = content.match(MODEL_PATTERNS.tableName)
|
|
281
|
+
const table_name = tableMatch ? tableMatch[1] : null
|
|
282
|
+
|
|
283
|
+
// Abstract class
|
|
284
|
+
const abstract = MODEL_PATTERNS.abstractClass.test(content)
|
|
285
|
+
|
|
286
|
+
// Default scope
|
|
287
|
+
const default_scope = MODEL_PATTERNS.defaultScope.test(content)
|
|
288
|
+
|
|
289
|
+
// Broadcasts
|
|
290
|
+
const broadcasts =
|
|
291
|
+
MODEL_PATTERNS.broadcastsTo.test(content) ||
|
|
292
|
+
MODEL_PATTERNS.broadcasts.test(content)
|
|
293
|
+
|
|
294
|
+
// Strict loading
|
|
295
|
+
const strict_loading = MODEL_PATTERNS.strictLoading.test(content)
|
|
296
|
+
|
|
297
|
+
// Turbo 8 morphing
|
|
298
|
+
const turboRefreshesMatch = content.match(MODEL_PATTERNS.turboRefreshes)
|
|
299
|
+
const turbo_refreshes_with = turboRefreshesMatch
|
|
300
|
+
? turboRefreshesMatch[1]
|
|
301
|
+
: null
|
|
302
|
+
|
|
303
|
+
// Devise modules
|
|
304
|
+
let devise_modules = []
|
|
305
|
+
const deviseMatch = content.match(MODEL_PATTERNS.devise)
|
|
306
|
+
if (deviseMatch) {
|
|
307
|
+
// Devise declaration can span multiple lines
|
|
308
|
+
let deviseStr = deviseMatch[1]
|
|
309
|
+
// Continue capturing if line ends with comma
|
|
310
|
+
const deviseStartIdx = content.indexOf(deviseMatch[0])
|
|
311
|
+
const afterMatch = content.slice(deviseStartIdx + deviseMatch[0].length)
|
|
312
|
+
const continuationLines = afterMatch.split('\n')
|
|
313
|
+
for (const line of continuationLines) {
|
|
314
|
+
const trimmed = line.trim()
|
|
315
|
+
if (trimmed.length === 0) continue
|
|
316
|
+
if (/^:/.test(trimmed) || /^,/.test(trimmed) || /^\w+.*:/.test(trimmed)) {
|
|
317
|
+
deviseStr += ' ' + trimmed
|
|
318
|
+
} else {
|
|
319
|
+
break
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
devise_modules = (deviseStr.match(/:(\w+)/g) || []).map((s) => s.slice(1))
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Searchable
|
|
326
|
+
let searchable = null
|
|
327
|
+
if (MODEL_PATTERNS.searchkick.test(content)) {
|
|
328
|
+
searchable = { gem: 'searchkick', scopes: [] }
|
|
329
|
+
} else if (MODEL_PATTERNS.pgSearchModel.test(content)) {
|
|
330
|
+
const pgScopes = []
|
|
331
|
+
const pgRe = new RegExp(MODEL_PATTERNS.pgSearchScope.source, 'gm')
|
|
332
|
+
while ((m = pgRe.exec(content))) {
|
|
333
|
+
pgScopes.push(m[1])
|
|
334
|
+
}
|
|
335
|
+
searchable = { gem: 'pg_search', scopes: pgScopes }
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Friendly ID
|
|
339
|
+
let friendly_id = null
|
|
340
|
+
if (MODEL_PATTERNS.extendFriendlyId.test(content)) {
|
|
341
|
+
const fidMatch = content.match(MODEL_PATTERNS.friendlyId)
|
|
342
|
+
friendly_id = { attribute: fidMatch ? fidMatch[1] : null }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Soft delete
|
|
346
|
+
let soft_delete = null
|
|
347
|
+
if (MODEL_PATTERNS.discardModel.test(content)) {
|
|
348
|
+
soft_delete = { strategy: 'discard' }
|
|
349
|
+
} else if (MODEL_PATTERNS.paranoid.test(content)) {
|
|
350
|
+
soft_delete = { strategy: 'paranoid' }
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// State machine
|
|
354
|
+
let state_machine = null
|
|
355
|
+
if (
|
|
356
|
+
MODEL_PATTERNS.includeAASM.test(content) ||
|
|
357
|
+
MODEL_PATTERNS.aasm.test(content)
|
|
358
|
+
) {
|
|
359
|
+
state_machine = { gem: 'aasm', detected: true }
|
|
360
|
+
} else if (MODEL_PATTERNS.stateMachine.test(content)) {
|
|
361
|
+
state_machine = { gem: 'state_machines', detected: true }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Paper trail
|
|
365
|
+
const paper_trail = MODEL_PATTERNS.hasPaperTrail.test(content)
|
|
366
|
+
|
|
367
|
+
// Audited
|
|
368
|
+
const audited = MODEL_PATTERNS.audited.test(content)
|
|
369
|
+
|
|
370
|
+
// STI base detection (has subclasses inheriting from this, detected elsewhere)
|
|
371
|
+
const sti_base = false
|
|
372
|
+
|
|
373
|
+
// Public instance method names (before first private/protected marker) with line ranges
|
|
374
|
+
const public_methods = []
|
|
375
|
+
const method_line_ranges = {}
|
|
376
|
+
{
|
|
377
|
+
const methodLines = content.split('\n')
|
|
378
|
+
let inPrivate = false
|
|
379
|
+
let currentMethodName = null
|
|
380
|
+
let currentMethodStart = null
|
|
381
|
+
let methodDepth = 0
|
|
382
|
+
for (let i = 0; i < methodLines.length; i++) {
|
|
383
|
+
const line = methodLines[i]
|
|
384
|
+
const lineNumber = i + 1
|
|
385
|
+
|
|
386
|
+
if (/^\s*(private|protected)\s*$/.test(line)) {
|
|
387
|
+
if (currentMethodName && !inPrivate) {
|
|
388
|
+
method_line_ranges[currentMethodName] = {
|
|
389
|
+
start: currentMethodStart,
|
|
390
|
+
end: lineNumber - 1,
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
inPrivate = true
|
|
394
|
+
currentMethodName = null
|
|
395
|
+
methodDepth = 0
|
|
396
|
+
continue
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const mm = line.match(/^\s*def\s+(\w+[?!=]?)/)
|
|
400
|
+
if (mm) {
|
|
401
|
+
// Close previous method
|
|
402
|
+
if (currentMethodName && !inPrivate) {
|
|
403
|
+
method_line_ranges[currentMethodName] = {
|
|
404
|
+
start: currentMethodStart,
|
|
405
|
+
end: lineNumber - 1,
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (!inPrivate && mm[1] !== 'initialize') {
|
|
410
|
+
public_methods.push(mm[1])
|
|
411
|
+
currentMethodName = mm[1]
|
|
412
|
+
currentMethodStart = lineNumber
|
|
413
|
+
methodDepth = 1
|
|
414
|
+
} else {
|
|
415
|
+
currentMethodName = null
|
|
416
|
+
}
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (currentMethodName && !inPrivate) {
|
|
421
|
+
if (
|
|
422
|
+
/\bdo\b|\bif\b(?!.*\bthen\b.*\bend\b)|\bcase\b|\bbegin\b/.test(
|
|
423
|
+
line,
|
|
424
|
+
) &&
|
|
425
|
+
!/\bend\b/.test(line)
|
|
426
|
+
) {
|
|
427
|
+
methodDepth++
|
|
428
|
+
}
|
|
429
|
+
if (/^\s*end\b/.test(line)) {
|
|
430
|
+
methodDepth--
|
|
431
|
+
if (methodDepth <= 0) {
|
|
432
|
+
method_line_ranges[currentMethodName] = {
|
|
433
|
+
start: currentMethodStart,
|
|
434
|
+
end: lineNumber,
|
|
435
|
+
}
|
|
436
|
+
currentMethodName = null
|
|
437
|
+
methodDepth = 0
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// Close final method
|
|
444
|
+
if (currentMethodName && !inPrivate) {
|
|
445
|
+
method_line_ranges[currentMethodName] = {
|
|
446
|
+
start: currentMethodStart,
|
|
447
|
+
end: methodLines.length,
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
class: detectedClass,
|
|
454
|
+
file: filePath,
|
|
455
|
+
type: isConcern ? 'concern' : 'model',
|
|
456
|
+
superclass,
|
|
457
|
+
abstract,
|
|
458
|
+
sti_base,
|
|
459
|
+
concerns,
|
|
460
|
+
extends: extends_,
|
|
461
|
+
associations,
|
|
462
|
+
validations,
|
|
463
|
+
custom_validators,
|
|
464
|
+
scopes,
|
|
465
|
+
scope_queries,
|
|
466
|
+
enums,
|
|
467
|
+
callbacks,
|
|
468
|
+
delegations,
|
|
469
|
+
encrypts,
|
|
470
|
+
normalizes,
|
|
471
|
+
token_generators,
|
|
472
|
+
has_secure_password,
|
|
473
|
+
attachments,
|
|
474
|
+
rich_text,
|
|
475
|
+
store_accessors,
|
|
476
|
+
table_name,
|
|
477
|
+
default_scope,
|
|
478
|
+
broadcasts,
|
|
479
|
+
strict_loading,
|
|
480
|
+
turbo_refreshes_with,
|
|
481
|
+
devise_modules,
|
|
482
|
+
searchable,
|
|
483
|
+
friendly_id,
|
|
484
|
+
soft_delete,
|
|
485
|
+
state_machine,
|
|
486
|
+
paper_trail,
|
|
487
|
+
audited,
|
|
488
|
+
public_methods,
|
|
489
|
+
method_line_ranges,
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* Extract all models from a manifest.
|
|
495
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
496
|
+
* @param {Array<{path: string}>} modelEntries
|
|
497
|
+
* @returns {Array<object>}
|
|
498
|
+
*/
|
|
499
|
+
export function extractModels(provider, modelEntries) {
|
|
500
|
+
const results = []
|
|
501
|
+
for (const entry of modelEntries) {
|
|
502
|
+
const model = extractModel(provider, entry.path)
|
|
503
|
+
if (model) results.push(model)
|
|
504
|
+
}
|
|
505
|
+
return results
|
|
506
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Realtime Extractor (#14)
|
|
3
|
+
* Extracts Action Cable channels, Turbo Streams, and WebSocket config.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { REALTIME_PATTERNS } from '../core/patterns.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract realtime information.
|
|
10
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
11
|
+
* @param {Array<{path: string, category: string}>} entries
|
|
12
|
+
* @param {{gems?: object}} gemInfo
|
|
13
|
+
* @returns {object}
|
|
14
|
+
*/
|
|
15
|
+
export function extractRealtime(provider, entries, gemInfo = {}) {
|
|
16
|
+
const gems = gemInfo.gems || {}
|
|
17
|
+
const result = {
|
|
18
|
+
adapter: {},
|
|
19
|
+
channels: [],
|
|
20
|
+
turbo_stream_from_usage: 0,
|
|
21
|
+
connection_auth: null,
|
|
22
|
+
anycable: !!gems.anycable || !!gems['anycable-rails'],
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Cable config
|
|
26
|
+
const cableYml = provider.readFile('config/cable.yml')
|
|
27
|
+
if (cableYml) {
|
|
28
|
+
const sections = cableYml.split(/\n(?=\w)/)
|
|
29
|
+
for (const section of sections) {
|
|
30
|
+
const envMatch = section.match(/^(\w+):/)
|
|
31
|
+
if (envMatch) {
|
|
32
|
+
const adapterMatch = section.match(REALTIME_PATTERNS.cableAdapter)
|
|
33
|
+
if (adapterMatch) {
|
|
34
|
+
result.adapter[envMatch[1]] = adapterMatch[1]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Connection auth
|
|
41
|
+
const connContent = provider.readFile(
|
|
42
|
+
'app/channels/application_cable/connection.rb',
|
|
43
|
+
)
|
|
44
|
+
if (connContent) {
|
|
45
|
+
if (REALTIME_PATTERNS.findVerifiedUser.test(connContent)) {
|
|
46
|
+
result.connection_auth = 'find_verified_user'
|
|
47
|
+
} else if (REALTIME_PATTERNS.rejectUnauthorized.test(connContent)) {
|
|
48
|
+
result.connection_auth = 'reject_unauthorized_connection'
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Channels
|
|
53
|
+
const channelEntries = entries.filter(
|
|
54
|
+
(e) => e.path.startsWith('app/channels/') && e.path.endsWith('_channel.rb'),
|
|
55
|
+
)
|
|
56
|
+
for (const entry of channelEntries) {
|
|
57
|
+
const content = provider.readFile(entry.path)
|
|
58
|
+
if (!content) continue
|
|
59
|
+
|
|
60
|
+
const classMatch = content.match(REALTIME_PATTERNS.channelClass)
|
|
61
|
+
if (!classMatch || classMatch[1] === 'ApplicationCable') continue
|
|
62
|
+
|
|
63
|
+
const channel = {
|
|
64
|
+
class: classMatch[1],
|
|
65
|
+
file: entry.path,
|
|
66
|
+
streams_from: [],
|
|
67
|
+
streams_for: [],
|
|
68
|
+
authenticated: false,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const fromRe = new RegExp(REALTIME_PATTERNS.streamFrom.source, 'g')
|
|
72
|
+
let m
|
|
73
|
+
while ((m = fromRe.exec(content))) {
|
|
74
|
+
channel.streams_from.push(m[1])
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const forRe = new RegExp(REALTIME_PATTERNS.streamFor.source, 'g')
|
|
78
|
+
while ((m = forRe.exec(content))) {
|
|
79
|
+
channel.streams_for.push(m[1].trim())
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Simple auth detection
|
|
83
|
+
if (content.includes('current_user') || content.includes('find_verified')) {
|
|
84
|
+
channel.authenticated = true
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
result.channels.push(channel)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Turbo stream from usage in views
|
|
91
|
+
const viewEntries = entries.filter((e) => e.path.startsWith('app/views/'))
|
|
92
|
+
for (const entry of viewEntries) {
|
|
93
|
+
const content = provider.readFile(entry.path)
|
|
94
|
+
if (!content) continue
|
|
95
|
+
const tsRe = new RegExp(REALTIME_PATTERNS.turboStreamFrom.source, 'g')
|
|
96
|
+
while (tsRe.exec(content)) {
|
|
97
|
+
result.turbo_stream_from_usage++
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return result
|
|
102
|
+
}
|