@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.
Files changed (90) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +210 -0
  3. package/bin/railsinsight.js +128 -0
  4. package/package.json +62 -0
  5. package/src/core/blast-radius.js +496 -0
  6. package/src/core/constants.js +39 -0
  7. package/src/core/context-loader.js +227 -0
  8. package/src/core/drift-detector.js +168 -0
  9. package/src/core/formatter.js +197 -0
  10. package/src/core/graph.js +510 -0
  11. package/src/core/indexer.js +595 -0
  12. package/src/core/patterns/api.js +27 -0
  13. package/src/core/patterns/auth.js +25 -0
  14. package/src/core/patterns/authorization.js +24 -0
  15. package/src/core/patterns/caching.js +19 -0
  16. package/src/core/patterns/component.js +18 -0
  17. package/src/core/patterns/config.js +15 -0
  18. package/src/core/patterns/controller.js +42 -0
  19. package/src/core/patterns/email.js +20 -0
  20. package/src/core/patterns/factory.js +31 -0
  21. package/src/core/patterns/gemfile.js +9 -0
  22. package/src/core/patterns/helper.js +10 -0
  23. package/src/core/patterns/job.js +12 -0
  24. package/src/core/patterns/model.js +123 -0
  25. package/src/core/patterns/realtime.js +17 -0
  26. package/src/core/patterns/route.js +27 -0
  27. package/src/core/patterns/schema.js +25 -0
  28. package/src/core/patterns/stimulus.js +13 -0
  29. package/src/core/patterns/storage.js +16 -0
  30. package/src/core/patterns/uploader.js +16 -0
  31. package/src/core/patterns/view.js +20 -0
  32. package/src/core/patterns/worker.js +12 -0
  33. package/src/core/patterns.js +27 -0
  34. package/src/core/scanner.js +394 -0
  35. package/src/core/version-detector.js +295 -0
  36. package/src/extractors/api.js +284 -0
  37. package/src/extractors/auth.js +853 -0
  38. package/src/extractors/authorization.js +785 -0
  39. package/src/extractors/caching.js +84 -0
  40. package/src/extractors/component.js +221 -0
  41. package/src/extractors/config.js +81 -0
  42. package/src/extractors/controller.js +273 -0
  43. package/src/extractors/coverage-snapshot.js +296 -0
  44. package/src/extractors/email.js +123 -0
  45. package/src/extractors/factory-registry.js +225 -0
  46. package/src/extractors/gemfile.js +440 -0
  47. package/src/extractors/helper.js +55 -0
  48. package/src/extractors/jobs.js +122 -0
  49. package/src/extractors/model.js +506 -0
  50. package/src/extractors/realtime.js +102 -0
  51. package/src/extractors/routes.js +251 -0
  52. package/src/extractors/schema.js +178 -0
  53. package/src/extractors/stimulus.js +149 -0
  54. package/src/extractors/storage.js +100 -0
  55. package/src/extractors/test-conventions.js +340 -0
  56. package/src/extractors/tier2.js +417 -0
  57. package/src/extractors/tier3.js +84 -0
  58. package/src/extractors/uploader.js +138 -0
  59. package/src/extractors/views.js +131 -0
  60. package/src/extractors/worker.js +62 -0
  61. package/src/git/diff-parser.js +132 -0
  62. package/src/providers/interface.js +12 -0
  63. package/src/providers/local-fs.js +318 -0
  64. package/src/server.js +71 -0
  65. package/src/tools/blast-radius-tools.js +129 -0
  66. package/src/tools/free-tools.js +44 -0
  67. package/src/tools/handlers/get-controller.js +93 -0
  68. package/src/tools/handlers/get-coverage-gaps.js +100 -0
  69. package/src/tools/handlers/get-deep-analysis.js +294 -0
  70. package/src/tools/handlers/get-domain-clusters.js +113 -0
  71. package/src/tools/handlers/get-factory-registry.js +43 -0
  72. package/src/tools/handlers/get-full-index.js +28 -0
  73. package/src/tools/handlers/get-model.js +108 -0
  74. package/src/tools/handlers/get-overview.js +153 -0
  75. package/src/tools/handlers/get-routes.js +18 -0
  76. package/src/tools/handlers/get-schema.js +40 -0
  77. package/src/tools/handlers/get-subgraph.js +82 -0
  78. package/src/tools/handlers/get-test-conventions.js +18 -0
  79. package/src/tools/handlers/get-well-tested-examples.js +51 -0
  80. package/src/tools/handlers/helpers.js +115 -0
  81. package/src/tools/handlers/index-project.js +36 -0
  82. package/src/tools/handlers/search-patterns.js +104 -0
  83. package/src/tools/index.js +34 -0
  84. package/src/tools/pro-tools.js +13 -0
  85. package/src/utils/file-reader.js +20 -0
  86. package/src/utils/inflector.js +223 -0
  87. package/src/utils/ruby-parser.js +115 -0
  88. package/src/utils/spec-style-detector.js +26 -0
  89. package/src/utils/token-counter.js +46 -0
  90. package/src/utils/yaml-parser.js +135 -0
@@ -0,0 +1,853 @@
1
+ /**
2
+ * Auth Extractor (#8)
3
+ * Detects authentication strategy (Devise, native Rails 8, JWT, etc.)
4
+ * and extracts deep configuration details including actual implementation.
5
+ */
6
+
7
+ import { AUTH_PATTERNS } from '../core/patterns.js'
8
+
9
+ // -------------------------------------------------------
10
+ // Helpers for reading native Rails 8 auth details
11
+ // -------------------------------------------------------
12
+
13
+ /** Extract method names (public) from Ruby source, grouped by purpose. */
14
+ function extractMethodNames(content) {
15
+ const methods = []
16
+ const lines = content.split('\n')
17
+ let inPrivate = false
18
+ for (const line of lines) {
19
+ if (/^\s*(private|protected)\s*$/.test(line)) {
20
+ inPrivate = true
21
+ continue
22
+ }
23
+ if (!inPrivate) {
24
+ const m = line.match(/^\s*def\s+(\w+)/)
25
+ if (m) methods.push(m[1])
26
+ }
27
+ }
28
+ return methods
29
+ }
30
+
31
+ /** Extract cookie configuration from auth concern content. */
32
+ function extractCookieConfig(content) {
33
+ const config = {}
34
+ const nameMatch = content.match(/cookies\.signed\[[:'""]?(\w+)['"":]?\]/)
35
+ if (nameMatch) config.name = nameMatch[1]
36
+
37
+ const httponlyMatch = content.match(/httponly:\s*(true|false)/)
38
+ if (httponlyMatch) config.httponly = httponlyMatch[1] === 'true'
39
+
40
+ const sameSiteMatch = content.match(/same_site:\s*:?(\w+)/)
41
+ if (sameSiteMatch) config.same_site = sameSiteMatch[1]
42
+
43
+ const secureMatch = content.match(/secure:\s*([^,\n]+)/)
44
+ if (secureMatch) config.secure = secureMatch[1].trim()
45
+
46
+ // Session duration: look for things like 30.days, 2.weeks, 1.year
47
+ const durationMatch = content.match(/(\d+)\.(days?|weeks?|months?|years?)/)
48
+ if (durationMatch) config.duration = `${durationMatch[1]} ${durationMatch[2]}`
49
+
50
+ return Object.keys(config).length > 0 ? config : null
51
+ }
52
+
53
+ /** Extract rate limiting declarations from content (Rails 8 native rate_limit). */
54
+ function extractRateLimits(content) {
55
+ const limits = []
56
+ const re =
57
+ /rate_limit\s+to:\s*(\d+),\s*within:\s*([^,\n]+?)(?:,\s*only:\s*(?:%i\[([^\]]+)\]|:(\w+)|\[([^\]]+)\]))?/g
58
+ let m
59
+ while ((m = re.exec(content))) {
60
+ limits.push({
61
+ to: parseInt(m[1], 10),
62
+ within: m[2].trim(),
63
+ only: m[3] || m[4] || m[5] || null,
64
+ })
65
+ }
66
+ return limits
67
+ }
68
+
69
+ /** Extract allow_unauthenticated_access declaration. */
70
+ function extractAllowUnauthenticated(content) {
71
+ const m = content.match(
72
+ /allow_unauthenticated_access(?:\s+only:\s*(?:%i\[([^\]]+)\]|:(\w+)|\[([^\]]+)\]))?/,
73
+ )
74
+ if (!m) return null
75
+ const only = (m[1] || m[2] || m[3] || '')
76
+ .replace(/[:%]/g, '')
77
+ .split(/\s+/)
78
+ .filter(Boolean)
79
+ return { only: only.length > 0 ? only : null }
80
+ }
81
+
82
+ /** Extract the key method calls inside each action method (first redirect_to / model call). */
83
+ function extractActionSummary(content, actionName) {
84
+ const lines = content.split('\n')
85
+ let inAction = false
86
+ let depth = 0
87
+ const keyCalls = []
88
+ for (const line of lines) {
89
+ if (new RegExp(`^\\s*def\\s+${actionName}\\b`).test(line)) {
90
+ inAction = true
91
+ depth = 0
92
+ continue
93
+ }
94
+ if (!inAction) continue
95
+ if (/^\s*def\s+\w+/.test(line) && depth === 0) break // next method
96
+ if (/\bdo\b|\bif\b|\bcase\b|\bbegin\b/.test(line)) depth++
97
+ if (/^\s*end\b/.test(line)) {
98
+ if (depth === 0) break
99
+ depth--
100
+ }
101
+ const trimmed = line.trim()
102
+ // Capture first few key expressions
103
+ if (
104
+ /^(redirect_to|render|head|@\w+\s*=|User\.|start_new_session|terminate_session|authenticate)/.test(
105
+ trimmed,
106
+ )
107
+ ) {
108
+ keyCalls.push(trimmed.replace(/\s+/g, ' ').slice(0, 120))
109
+ if (keyCalls.length >= 3) break
110
+ }
111
+ }
112
+ return keyCalls.join('; ') || null
113
+ }
114
+
115
+ /** Extract all method bodies as a map of { name → body_text }. */
116
+ function extractMethodBodies(content) {
117
+ const bodies = {}
118
+ const lines = content.split('\n')
119
+ let currentMethod = null
120
+ let depth = 0
121
+ const bodyLines = []
122
+
123
+ for (const line of lines) {
124
+ const defMatch = line.match(/^\s*def\s+(\w+)/)
125
+ if (defMatch && depth === 0) {
126
+ if (currentMethod) bodies[currentMethod] = bodyLines.join('\n').trim()
127
+ currentMethod = defMatch[1]
128
+ bodyLines.length = 0
129
+ depth = 0
130
+ continue
131
+ }
132
+ if (currentMethod) {
133
+ if (/\bdo\b|\bif\b|\bcase\b|\bbegin\b|\bdef\b/.test(line)) depth++
134
+ if (/^\s*end\b/.test(line)) {
135
+ if (depth === 0) {
136
+ bodies[currentMethod] = bodyLines.join('\n').trim()
137
+ currentMethod = null
138
+ bodyLines.length = 0
139
+ continue
140
+ }
141
+ depth--
142
+ }
143
+ bodyLines.push(line)
144
+ }
145
+ }
146
+ if (currentMethod) bodies[currentMethod] = bodyLines.join('\n').trim()
147
+ return bodies
148
+ }
149
+
150
+ /** Build a rich per-method detail object from the auth concern. */
151
+ function extractConcernMethodDetails(content) {
152
+ const bodies = extractMethodBodies(content)
153
+ const details = {}
154
+
155
+ for (const [name, body] of Object.entries(bodies)) {
156
+ const info = {}
157
+
158
+ // Detect purpose from method name
159
+ if (name === 'require_authentication') {
160
+ info.type = 'before_action'
161
+ info.purpose = 'Redirects unauthenticated users to login'
162
+ const redirectMatch = body.match(/redirect_to\s+([\w_]+(?:_path|_url)?)/)
163
+ if (redirectMatch) info.redirect_target = redirectMatch[1]
164
+ info.stores_url =
165
+ /session\[.*requested_url|request_url|store.*url|forwarding_url/.test(
166
+ body,
167
+ )
168
+ } else if (name === 'resume_session') {
169
+ info.purpose = 'Restores session from signed cookie on each request'
170
+ const callMatch = body.match(/(\w+)\(/)
171
+ if (callMatch) info.calls = callMatch[1]
172
+ } else if (name === 'find_session_by_cookie') {
173
+ info.purpose = 'Finds non-expired session record using cookie value'
174
+ // Cookie name
175
+ const cookieNameMatch =
176
+ body.match(/cookies\.signed(?:\.permanent)?\[[:'""]?(\w+)['"":]?\]/) ||
177
+ content.match(/cookies\.signed(?:\.permanent)?\[[:'""]?(\w+)['"":]?\]/)
178
+ if (cookieNameMatch) info.cookie_name = cookieNameMatch[1]
179
+ // Session duration / max age
180
+ const durMatch =
181
+ body.match(/(\d+)\.(days?|weeks?|months?|years?|hours?)/) ||
182
+ content.match(/(\d+)\.(days?|weeks?|months?|years?|hours?)/)
183
+ if (durMatch) info.session_max_age = `${durMatch[1]}.${durMatch[2]}`
184
+ // Cookie type
185
+ if (
186
+ /cookies\.signed\.permanent/.test(body) ||
187
+ /cookies\.signed\.permanent/.test(content)
188
+ ) {
189
+ info.cookie_type = 'signed.permanent'
190
+ } else if (
191
+ /cookies\.signed/.test(body) ||
192
+ /cookies\.signed/.test(content)
193
+ ) {
194
+ info.cookie_type = 'signed'
195
+ }
196
+ } else if (name === 'start_new_session_for') {
197
+ info.purpose =
198
+ 'Creates new database session record and sets signed cookie'
199
+ const sessCreateMatch = body.match(/Session\.create[!(]?\s*([^)]+)/)
200
+ if (sessCreateMatch)
201
+ info.creates = `Session.create(${sessCreateMatch[1].substring(0, 80).trim()})`
202
+ // Cookie config from this method's body first, then full content
203
+ const cfg = extractCookieConfig(body) || extractCookieConfig(content)
204
+ if (cfg) info.cookie_config = cfg
205
+ } else if (name === 'terminate_session') {
206
+ info.purpose = 'Destroys session record and deletes cookie'
207
+ if (/destroy/.test(body)) info.destroys = 'Current.session'
208
+ if (/cookies\.delete/.test(body)) info.deletes_cookie = true
209
+ } else if (/allow_unauthenticated_access/.test(name)) {
210
+ info.type = 'class_method'
211
+ info.purpose =
212
+ 'Controller macro to skip require_authentication for specified actions'
213
+ } else {
214
+ // Generic
215
+ const firstLine = body.split('\n').find((l) => l.trim().length > 0)
216
+ if (firstLine) info.body_start = firstLine.trim().substring(0, 100)
217
+ }
218
+
219
+ if (Object.keys(info).length > 0) details[name] = info
220
+ }
221
+ return details
222
+ }
223
+
224
+ /** Extract Current model detail from current.rb content. */
225
+ function extractCurrentModelDetail(content) {
226
+ if (!content) return null
227
+ const attrs = []
228
+ const attrRe = /^\s*attribute\s+:(\w+)/gm
229
+ let m
230
+ while ((m = attrRe.exec(content))) attrs.push(m[1])
231
+
232
+ const delegates = []
233
+ const delRe =
234
+ /^\s*delegate\s+:(\w+),\s*to:\s*:(\w+)(?:,\s*allow_nil:\s*(true|false))?/gm
235
+ while ((m = delRe.exec(content))) {
236
+ delegates.push({
237
+ method: m[1],
238
+ to: m[2],
239
+ allow_nil: m[3] === 'true' || true,
240
+ })
241
+ }
242
+
243
+ const usageParts = []
244
+ if (attrs.length > 0) usageParts.push(`Current.${attrs[0]}`)
245
+ if (delegates.length > 0) usageParts.push(`Current.${delegates[0].method}`)
246
+
247
+ return {
248
+ file: 'app/models/current.rb',
249
+ class: 'Current',
250
+ superclass: 'ActiveSupport::CurrentAttributes',
251
+ attributes: attrs,
252
+ delegates,
253
+ usage: `Provides ${usageParts.join(' and ')} throughout the request lifecycle`,
254
+ }
255
+ }
256
+
257
+ /** Scan all loaded content for common API auth patterns, return found/not_found map. */
258
+ function scanForApiAuthPatterns(provider, entries) {
259
+ // Scan controller entries + Gemfile only (already small, fast)
260
+ const controllerEntries = entries.filter(
261
+ (e) =>
262
+ e.category === 'controller' ||
263
+ e.categoryName === 'controllers' ||
264
+ e.category === 'config' ||
265
+ e.categoryName === 'config',
266
+ )
267
+ const contents = []
268
+ for (const entry of controllerEntries) {
269
+ const c = provider.readFile(entry.path)
270
+ if (c) contents.push(c)
271
+ }
272
+ const gemfileContent = provider.readFile('Gemfile') || ''
273
+ const allContent = [...contents, gemfileContent].join('\n')
274
+
275
+ const patterns = [
276
+ {
277
+ key: 'jwt',
278
+ searched: ['jwt', 'JSON::JWT', 'jwt_token'],
279
+ re: /\bjwt\b|json_web_token|JWT\./i,
280
+ },
281
+ {
282
+ key: 'api_key',
283
+ searched: ['api_key', 'X-Api-Key', 'x_api_key'],
284
+ re: /api[_\-]key|x-api-key/i,
285
+ },
286
+ {
287
+ key: 'bearer_token',
288
+ searched: ['bearer', 'Authorization: Bearer'],
289
+ re: /bearer|authenticate_with_http_token/i,
290
+ },
291
+ {
292
+ key: 'token_authenticatable',
293
+ searched: ['token_authenticatable', 'has_secure_token :auth'],
294
+ re: /token_authenticatable|has_secure_token\s+:auth/i,
295
+ },
296
+ {
297
+ key: 'doorkeeper_oauth',
298
+ searched: ['doorkeeper', 'oauth'],
299
+ re: /doorkeeper|oauth/i,
300
+ },
301
+ {
302
+ key: 'devise_jwt',
303
+ searched: ['devise-jwt'],
304
+ re: /devise-jwt|devise\/jwt/i,
305
+ },
306
+ ]
307
+
308
+ const results = {}
309
+ for (const { key, searched, re } of patterns) {
310
+ results[key] = { found: re.test(allContent), searched }
311
+ }
312
+ return results
313
+ }
314
+
315
+ /** Derive auth-related routes from the routes extraction. */
316
+ function extractAuthRoutes(routesData) {
317
+ if (!routesData) return {}
318
+ const authKeywords = [
319
+ 'session',
320
+ 'registration',
321
+ 'password',
322
+ 'login',
323
+ 'logout',
324
+ 'signup',
325
+ 'sign_in',
326
+ 'sign_out',
327
+ ]
328
+ const routes = routesData.routes || []
329
+ const authRoutes = {}
330
+ for (const r of routes) {
331
+ const path = (r.path || r.pattern || '').toLowerCase()
332
+ const ctrl = (r.controller || '').toLowerCase()
333
+ if (authKeywords.some((k) => path.includes(k) || ctrl.includes(k))) {
334
+ const key = `${r.verb || r.method || 'GET'} ${r.path || r.pattern}`
335
+ authRoutes[key] = `${r.controller}#${r.action}`
336
+ }
337
+ }
338
+ return authRoutes
339
+ }
340
+
341
+ /**
342
+ * Extract authentication information.
343
+ * @param {import('../providers/interface.js').FileProvider} provider
344
+ * @param {Array<{path: string, category: string}>} entries - scanned entries
345
+ * @param {{gems?: object}} gemInfo - extracted gem information
346
+ * @param {object|null} schemaData - pre-extracted schema (optional)
347
+ * @returns {object}
348
+ */
349
+ export function extractAuth(
350
+ provider,
351
+ entries,
352
+ gemInfo = {},
353
+ schemaData = null,
354
+ ) {
355
+ const gems = gemInfo.gems || {}
356
+ const result = {
357
+ primary_strategy: null,
358
+ devise: null,
359
+ native_auth: null,
360
+ jwt: null,
361
+ two_factor: null,
362
+ omniauth: null,
363
+ has_secure_password: false,
364
+ }
365
+
366
+ const hasDevise = !!gems.devise
367
+ const hasJwt = !!gems.jwt || !!gems['devise-jwt']
368
+ const hasTwoFactor =
369
+ !!gems['devise-two-factor'] || !!gems.rotp || !!gems.webauthn
370
+ const hasOmniauth = !!gems.omniauth
371
+
372
+ // Detect Devise
373
+ if (hasDevise) {
374
+ result.primary_strategy = 'devise'
375
+ result.devise = {
376
+ models: {},
377
+ custom_controllers: [],
378
+ config: {},
379
+ }
380
+
381
+ // Parse Devise initializer config
382
+ const deviseConfig = provider.readFile('config/initializers/devise.rb')
383
+ if (deviseConfig) {
384
+ const configRe = new RegExp(AUTH_PATTERNS.deviseConfig.source, 'g')
385
+ let m
386
+ while ((m = configRe.exec(deviseConfig))) {
387
+ const key = m[1]
388
+ const val = m[2].trim()
389
+ result.devise.config[key] = val
390
+ }
391
+ }
392
+
393
+ // Parse models for devise declarations
394
+ const modelEntries = entries.filter(
395
+ (e) => e.category === 'model' || e.categoryName === 'models',
396
+ )
397
+ for (const entry of modelEntries) {
398
+ const content = provider.readFile(entry.path)
399
+ if (!content) continue
400
+
401
+ const deviseMatch = content.match(AUTH_PATTERNS.deviseModules)
402
+ if (deviseMatch) {
403
+ const className = entry.path
404
+ .split('/')
405
+ .pop()
406
+ .replace('.rb', '')
407
+ .split('_')
408
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
409
+ .join('')
410
+
411
+ let fullDecl = deviseMatch[1]
412
+ const lines = content.split('\n')
413
+ const matchLine =
414
+ content.slice(0, deviseMatch.index).split('\n').length - 1
415
+ for (let li = matchLine + 1; li < lines.length; li++) {
416
+ const ltrim = lines[li].trim()
417
+ if (ltrim.startsWith(':') || /^\w+.*:/.test(ltrim)) {
418
+ fullDecl += ' ' + ltrim
419
+ } else {
420
+ break
421
+ }
422
+ }
423
+
424
+ const modulePart = fullDecl.split(/\w+:\s*\[/)[0]
425
+ const modules =
426
+ modulePart.match(/:(\w+)/g)?.map((m) => m.slice(1)) || []
427
+
428
+ const model = { modules, omniauth_providers: [] }
429
+
430
+ const oaMatch = content.match(AUTH_PATTERNS.omniauthProviders)
431
+ if (oaMatch) {
432
+ model.omniauth_providers =
433
+ oaMatch[1].match(/:(\w+)/g)?.map((p) => p.slice(1)) || []
434
+ }
435
+
436
+ result.devise.models[className] = model
437
+ }
438
+
439
+ if (AUTH_PATTERNS.hasSecurePassword.test(content)) {
440
+ result.has_secure_password = true
441
+ }
442
+ }
443
+
444
+ const controllerEntries = entries.filter(
445
+ (e) => e.category === 'controller' || e.categoryName === 'controllers',
446
+ )
447
+ for (const entry of controllerEntries) {
448
+ const content = provider.readFile(entry.path)
449
+ if (!content) continue
450
+ const devCtrlMatch = content.match(AUTH_PATTERNS.deviseController)
451
+ if (devCtrlMatch) {
452
+ const namespace = content.match(/class\s+(\w+)::/)
453
+ const name = namespace
454
+ ? `${namespace[1]}::${devCtrlMatch[1]}`
455
+ : devCtrlMatch[1]
456
+ result.devise.custom_controllers.push(name)
457
+ }
458
+ }
459
+ }
460
+
461
+ // Detect native Rails 8 auth
462
+ const currentContent = provider.readFile('app/models/current.rb')
463
+ if (currentContent && AUTH_PATTERNS.currentAttributes.test(currentContent)) {
464
+ if (!result.primary_strategy) result.primary_strategy = 'native'
465
+
466
+ // --- Deep extraction for native Rails 8 auth ---
467
+ const native = {
468
+ strategy: 'native_rails8',
469
+ description:
470
+ 'Rails 8 built-in authentication with database-backed sessions, signed permanent cookies, and CurrentAttributes pattern',
471
+ models: {},
472
+ controllers: {},
473
+ routes: {},
474
+ security_features: {},
475
+ related_files: [],
476
+ }
477
+
478
+ // 1. Current model — extract full detail and expose as dedicated top-level section
479
+ const currentDetail = extractCurrentModelDetail(currentContent)
480
+ if (currentDetail) {
481
+ // Backward-compat shortcut
482
+ native.attributes = currentDetail.attributes
483
+ // Dedicated section so LLM doesn't need to read the file
484
+ native.current_attributes = currentDetail
485
+ // Also available in models map
486
+ native.models['Current'] = {
487
+ file: 'app/models/current.rb',
488
+ type: 'ActiveSupport::CurrentAttributes',
489
+ attributes: currentDetail.attributes,
490
+ delegates: currentDetail.delegates,
491
+ usage: currentDetail.usage,
492
+ }
493
+ }
494
+ native.related_files.push('app/models/current.rb')
495
+
496
+ // 2. Session model
497
+ const sessionContent = provider.readFile('app/models/session.rb')
498
+ if (sessionContent) {
499
+ const sessionInfo = { file: 'app/models/session.rb' }
500
+ const belongsMatch = sessionContent.match(/belongs_to\s+:(\w+)/)
501
+ if (belongsMatch) sessionInfo.belongs_to = belongsMatch[1]
502
+ // Pull columns from schema if available
503
+ if (schemaData) {
504
+ const table = schemaData.tables?.find((t) => t.name === 'sessions')
505
+ if (table) sessionInfo.columns = table.columns.map((c) => c.name)
506
+ }
507
+ native.models['Session'] = sessionInfo
508
+ native.related_files.push('app/models/session.rb')
509
+ }
510
+
511
+ // 3. User model auth features
512
+ const userContent = provider.readFile('app/models/user.rb')
513
+ if (userContent) {
514
+ const userInfo = { file: 'app/models/user.rb', auth_features: {} }
515
+ if (AUTH_PATTERNS.hasSecurePassword.test(userContent)) {
516
+ userInfo.auth_features.has_secure_password = true
517
+ result.has_secure_password = true
518
+ }
519
+ if (/authenticate_by/.test(userContent)) {
520
+ userInfo.auth_features.authenticate_by =
521
+ 'User.authenticate_by(email:, password:)'
522
+ }
523
+ // Email/password validations
524
+ const emailValidation = userContent.match(
525
+ /validates?\s+:email(?:_address)?,([^\n]+)/,
526
+ )
527
+ if (emailValidation)
528
+ userInfo.auth_features.email_validation = emailValidation[1].trim()
529
+ // Email normalization
530
+ if (/normalize|strip|downcase/.test(userContent)) {
531
+ userInfo.auth_features.email_normalization =
532
+ 'normalizes email (strips and downcases)'
533
+ }
534
+ // Roles
535
+ const roleEnum = userContent.match(
536
+ /enum\s+:?role[:\s,]+[\{|\[]([^\}\]]+)[\}|\]]/,
537
+ )
538
+ if (roleEnum) {
539
+ const roles =
540
+ roleEnum[1].match(/\w+/g)?.filter((v) => !/^\d+$/.test(v)) || []
541
+ userInfo.auth_features.roles = roles
542
+ }
543
+ // Users table columns from schema
544
+ if (schemaData) {
545
+ const table = schemaData.tables?.find((t) => t.name === 'users')
546
+ if (table)
547
+ userInfo.columns = table.columns.map((c) => ({
548
+ name: c.name,
549
+ type: c.type,
550
+ constraints: c.constraints,
551
+ }))
552
+ }
553
+ native.models['User'] = userInfo
554
+ native.related_files.push('app/models/user.rb')
555
+
556
+ // Token generators (generates_token_for)
557
+ const tokenGenerators = []
558
+ const tokenGenRe = /generates_token_for\s+:(\w+)/g
559
+ let tm
560
+ while ((tm = tokenGenRe.exec(userContent))) {
561
+ tokenGenerators.push(tm[1])
562
+ }
563
+ if (tokenGenerators.length > 0) {
564
+ userInfo.auth_features.token_generators = tokenGenerators
565
+ }
566
+ }
567
+
568
+ // 4. Auth concern (ApplicationController includes Authentication)
569
+ const authConcernPaths = [
570
+ 'app/controllers/concerns/authentication.rb',
571
+ 'app/controllers/concerns/authenticatable.rb',
572
+ ]
573
+ let authConcernContent = null
574
+ let authConcernFile = null
575
+ for (const p of authConcernPaths) {
576
+ const c = provider.readFile(p)
577
+ if (c) {
578
+ authConcernContent = c
579
+ authConcernFile = p
580
+ break
581
+ }
582
+ }
583
+ // Also search entries for a concern with "authentication" in path
584
+ if (!authConcernContent) {
585
+ const concernEntry = entries.find(
586
+ (e) =>
587
+ (e.categoryName === 'controllers' || e.category === 'controller') &&
588
+ e.path.includes('concerns') &&
589
+ e.path.toLowerCase().includes('auth'),
590
+ )
591
+ if (concernEntry) {
592
+ authConcernContent = provider.readFile(concernEntry.path)
593
+ authConcernFile = concernEntry.path
594
+ }
595
+ }
596
+
597
+ if (authConcernContent) {
598
+ const methods = extractMethodNames(authConcernContent)
599
+ const cookieConfig = extractCookieConfig(authConcernContent)
600
+ const optOut = extractAllowUnauthenticated(authConcernContent)
601
+ const methodDetails = extractConcernMethodDetails(authConcernContent)
602
+
603
+ native.controllers['authentication_concern'] = {
604
+ file: authConcernFile,
605
+ included_in: 'ApplicationController',
606
+ methods: methodDetails,
607
+ // Backward-compat: summary string per method (used by older callers)
608
+ key_methods: methods.reduce((acc, method) => {
609
+ const summary = extractActionSummary(authConcernContent, method)
610
+ acc[method] = summary || 'defined'
611
+ return acc
612
+ }, {}),
613
+ cookie_config: cookieConfig,
614
+ opt_out_method: optOut ? 'allow_unauthenticated_access' : null,
615
+ }
616
+ native.related_files.push(authConcernFile)
617
+ }
618
+
619
+ // 5. SessionsController
620
+ const sessionsCtrlPaths = ['app/controllers/sessions_controller.rb']
621
+ let sessionsContent = null
622
+ let sessionsFile = null
623
+ for (const p of sessionsCtrlPaths) {
624
+ const c = provider.readFile(p)
625
+ if (c) {
626
+ sessionsContent = c
627
+ sessionsFile = p
628
+ break
629
+ }
630
+ }
631
+ if (!sessionsContent) {
632
+ const entry = entries.find(
633
+ (e) =>
634
+ (e.categoryName === 'controllers' || e.category === 'controller') &&
635
+ e.path.toLowerCase().includes('sessions_controller'),
636
+ )
637
+ if (entry) {
638
+ sessionsContent = provider.readFile(entry.path)
639
+ sessionsFile = entry.path
640
+ }
641
+ }
642
+ if (sessionsContent) {
643
+ const rateLimits = extractRateLimits(sessionsContent)
644
+ const unauthAccess = extractAllowUnauthenticated(sessionsContent)
645
+ const actions = extractMethodNames(sessionsContent).filter(
646
+ (m) => !m.startsWith('_'),
647
+ )
648
+ const ctrlInfo = {
649
+ file: sessionsFile,
650
+ actions,
651
+ rate_limiting: rateLimits.length > 0 ? rateLimits : null,
652
+ allow_unauthenticated_access: unauthAccess,
653
+ login_flow: null,
654
+ }
655
+ // Detect authenticate_by pattern
656
+ if (/authenticate_by/.test(sessionsContent)) {
657
+ const redirectMatch = sessionsContent.match(/redirect_to\s+([^\n,]+)/)
658
+ ctrlInfo.login_flow = `User.authenticate_by(email:, password:) → start_new_session_for → ${redirectMatch ? redirectMatch[1].trim() : 'redirect'}`
659
+ }
660
+ native.controllers['SessionsController'] = ctrlInfo
661
+ native.related_files.push(sessionsFile)
662
+ }
663
+
664
+ // 6. RegistrationsController
665
+ const regPaths = ['app/controllers/registrations_controller.rb']
666
+ let regContent = null
667
+ let regFile = null
668
+ for (const p of regPaths) {
669
+ const c = provider.readFile(p)
670
+ if (c) {
671
+ regContent = c
672
+ regFile = p
673
+ break
674
+ }
675
+ }
676
+ if (regContent) {
677
+ native.controllers['RegistrationsController'] = {
678
+ file: regFile,
679
+ actions: extractMethodNames(regContent).filter(
680
+ (m) => !m.startsWith('_'),
681
+ ),
682
+ }
683
+ native.related_files.push(regFile)
684
+ }
685
+
686
+ // 7. PasswordsController
687
+ const pwdPaths = ['app/controllers/passwords_controller.rb']
688
+ let pwdContent = null
689
+ let pwdFile = null
690
+ for (const p of pwdPaths) {
691
+ const c = provider.readFile(p)
692
+ if (c) {
693
+ pwdContent = c
694
+ pwdFile = p
695
+ break
696
+ }
697
+ }
698
+ if (pwdContent) {
699
+ const mailerMatch = pwdContent.match(/(\w+Mailer)/)
700
+ const tokenMatch = pwdContent.match(
701
+ /password_reset_token|with_reset_token/,
702
+ )
703
+ native.controllers['PasswordsController'] = {
704
+ file: pwdFile,
705
+ actions: extractMethodNames(pwdContent).filter(
706
+ (m) => !m.startsWith('_'),
707
+ ),
708
+ reset_flow: tokenMatch
709
+ ? 'email → token → reset form → update password'
710
+ : null,
711
+ token_method: tokenMatch
712
+ ? tokenMatch[1] || 'password_reset_token'
713
+ : null,
714
+ mailer: mailerMatch ? mailerMatch[1] : null,
715
+ }
716
+ native.related_files.push(pwdFile)
717
+ }
718
+
719
+ // 8. ApplicationController — check what it includes
720
+ const appCtrlContent = provider.readFile(
721
+ 'app/controllers/application_controller.rb',
722
+ )
723
+ if (appCtrlContent) {
724
+ native.related_files.push('app/controllers/application_controller.rb')
725
+ if (/include\s+Authentication/.test(appCtrlContent)) {
726
+ if (native.controllers['authentication_concern']) {
727
+ native.controllers['authentication_concern'].included_in =
728
+ 'ApplicationController'
729
+ }
730
+ }
731
+ }
732
+
733
+ // 9. Security features summary
734
+ native.security_features = {
735
+ csrf: 'Rails built-in authenticity tokens',
736
+ }
737
+ if (authConcernContent) {
738
+ const cookieCfg = extractCookieConfig(authConcernContent)
739
+ if (cookieCfg) {
740
+ native.security_features.cookie_security =
741
+ [
742
+ cookieCfg.httponly ? 'httponly' : null,
743
+ cookieCfg.same_site ? `same_site: ${cookieCfg.same_site}` : null,
744
+ cookieCfg.secure ? `secure: ${cookieCfg.secure}` : null,
745
+ ]
746
+ .filter(Boolean)
747
+ .join(', ') || null
748
+ if (cookieCfg.duration)
749
+ native.security_features.session_expiry = cookieCfg.duration
750
+ }
751
+ }
752
+ if (sessionsContent) {
753
+ const rl = extractRateLimits(sessionsContent)
754
+ if (rl.length > 0) {
755
+ native.security_features.rate_limiting = rl
756
+ .map(
757
+ (r) =>
758
+ `${r.to} requests per ${r.within}${r.only ? ` (only: ${r.only})` : ''}`,
759
+ )
760
+ .join(', ')
761
+ }
762
+ }
763
+ if (
764
+ userContent &&
765
+ /password.*minimum|minimum.*\d+|length.*minimum/.test(
766
+ provider.readFile('app/models/user.rb') || '',
767
+ )
768
+ ) {
769
+ native.security_features.password_requirements =
770
+ 'minimum length validation'
771
+ }
772
+ if (sessionContent) {
773
+ native.security_features.session_tracking =
774
+ 'IP address and user agent stored per session'
775
+ }
776
+
777
+ // Cross-reference token generators with security features
778
+ const userTokenGens = native.models['User']?.auth_features?.token_generators
779
+ if (userTokenGens?.includes('password_reset')) {
780
+ native.security_features.password_reset_tokens =
781
+ 'generates_token_for :password_reset'
782
+ }
783
+
784
+ // Remove duplicates from related_files
785
+ native.related_files = [...new Set(native.related_files)]
786
+
787
+ // 10. API authentication — explicit search for token/JWT/OAuth patterns
788
+ const apiAuthPatterns = scanForApiAuthPatterns(provider, entries)
789
+ const apiAuthPresent = Object.values(apiAuthPatterns).some((v) => v.found)
790
+ native.api_authentication = {
791
+ present: apiAuthPresent,
792
+ searched_patterns: Object.entries(apiAuthPatterns).map(([key, v]) => ({
793
+ pattern: key,
794
+ searched: v.searched,
795
+ found: v.found,
796
+ })),
797
+ summary: apiAuthPresent
798
+ ? 'Detected API authentication patterns (see searched_patterns for details)'
799
+ : 'No API authentication found. App uses native session-cookie auth only. All searches returned not-found.',
800
+ }
801
+
802
+ native.has_sessions_controller = !!sessionsContent
803
+ result.native_auth = native
804
+ }
805
+
806
+ // JWT
807
+ if (hasJwt) {
808
+ result.jwt = { gem: gems['devise-jwt'] ? 'devise-jwt' : 'jwt' }
809
+ if (!result.primary_strategy) result.primary_strategy = 'jwt'
810
+ }
811
+
812
+ // Two-factor
813
+ if (hasTwoFactor) {
814
+ const gem = gems['devise-two-factor']
815
+ ? 'devise-two-factor'
816
+ : gems.rotp
817
+ ? 'rotp'
818
+ : 'webauthn'
819
+ result.two_factor = { gem }
820
+ }
821
+
822
+ // OmniAuth (standalone)
823
+ if (hasOmniauth && !hasDevise) {
824
+ result.omniauth = { providers: [] }
825
+ if (!result.primary_strategy) result.primary_strategy = 'omniauth'
826
+ const initContent = provider.readFile('config/initializers/omniauth.rb')
827
+ if (initContent) {
828
+ const provRe = new RegExp(AUTH_PATTERNS.omniauthProvider.source, 'g')
829
+ let pm
830
+ while ((pm = provRe.exec(initContent))) {
831
+ result.omniauth.providers.push(pm[1])
832
+ }
833
+ }
834
+ }
835
+
836
+ // Check models for has_secure_password if not yet found
837
+ if (!result.has_secure_password) {
838
+ const modelEntries = entries.filter(
839
+ (e) => e.category === 'model' || e.categoryName === 'models',
840
+ )
841
+ for (const entry of modelEntries) {
842
+ const content = provider.readFile(entry.path)
843
+ if (content && AUTH_PATTERNS.hasSecurePassword.test(content)) {
844
+ result.has_secure_password = true
845
+ if (!result.primary_strategy)
846
+ result.primary_strategy = 'has_secure_password'
847
+ break
848
+ }
849
+ }
850
+ }
851
+
852
+ return result
853
+ }