@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,785 @@
1
+ /**
2
+ * Authorization Extractor (#9)
3
+ * Detects authorization strategy (Pundit, CanCanCan, Action Policy, custom RBAC)
4
+ * and extracts a comprehensive RBAC analysis including guard methods,
5
+ * role predicates, controller enforcement map, and domain role disambiguation.
6
+ */
7
+
8
+ import { AUTHORIZATION_PATTERNS } from '../core/patterns.js'
9
+
10
+ // Common authorization gem names to search for and report
11
+ const SEARCHED_LIBRARIES = [
12
+ 'pundit',
13
+ 'cancancan',
14
+ 'cancan',
15
+ 'rolify',
16
+ 'action_policy',
17
+ 'access-granted',
18
+ ]
19
+
20
+ // -------------------------------------------------------
21
+ // Helpers for deep custom RBAC extraction
22
+ // -------------------------------------------------------
23
+
24
+ /** Extract method bodies from Ruby source as { name → body }. */
25
+ function extractMethodBodies(content) {
26
+ const bodies = {}
27
+ const lines = content.split('\n')
28
+ let currentMethod = null
29
+ let depth = 0
30
+ const bodyLines = []
31
+
32
+ for (const line of lines) {
33
+ const defMatch = line.match(/^\s*def\s+(\w+[?!]?)/)
34
+ if (defMatch && depth === 0) {
35
+ if (currentMethod) bodies[currentMethod] = bodyLines.join('\n').trim()
36
+ currentMethod = defMatch[1]
37
+ bodyLines.length = 0
38
+ depth = 0
39
+ continue
40
+ }
41
+ if (currentMethod) {
42
+ if (/\bdo\b|\bif\b|\bcase\b|\bbegin\b|\bdef\b/.test(line)) depth++
43
+ if (/^\s*end\b/.test(line)) {
44
+ if (depth === 0) {
45
+ bodies[currentMethod] = bodyLines.join('\n').trim()
46
+ currentMethod = null
47
+ bodyLines.length = 0
48
+ continue
49
+ }
50
+ depth--
51
+ }
52
+ bodyLines.push(line)
53
+ }
54
+ }
55
+ if (currentMethod) bodies[currentMethod] = bodyLines.join('\n').trim()
56
+ return bodies
57
+ }
58
+
59
+ /** Parse the authorization concern for guard methods, helpers, error handling. */
60
+ function parseConcern(content, filePath) {
61
+ if (!content) return null
62
+
63
+ const concern = {
64
+ file: filePath,
65
+ included_in: null,
66
+ error_class: null,
67
+ helper_methods_exposed_to_views: [],
68
+ guard_methods: {},
69
+ error_handling: null,
70
+ }
71
+
72
+ // Detect error class
73
+ const errorClassMatch = content.match(
74
+ /class\s+(\w+(?:::\w+)*Error)\s*<\s*(StandardError|RuntimeError)/,
75
+ )
76
+ if (errorClassMatch) concern.error_class = errorClassMatch[1]
77
+
78
+ // Detect helper_method declarations
79
+ const helperMatch = content.match(/helper_method\s+([^\n]+)/)
80
+ if (helperMatch) {
81
+ concern.helper_methods_exposed_to_views = helperMatch[1]
82
+ .split(',')
83
+ .map((s) => s.trim().replace(/^:/, ''))
84
+ .filter(Boolean)
85
+ }
86
+
87
+ // Extract guard methods (require_*! pattern)
88
+ const bodies = extractMethodBodies(content)
89
+ for (const [name, body] of Object.entries(bodies)) {
90
+ if (/^require_\w+!$/.test(name)) {
91
+ const guard = { requirement: null, raises: null }
92
+ // Look for the predicate check
93
+ const predicateMatch =
94
+ body.match(/unless\s+(?:Current\.user\.)?(\w+\?)/) ||
95
+ body.match(/raise.*unless.*?(\w+\?)/) ||
96
+ body.match(/if\s+(?:!|not\s)(?:Current\.user\.)?(\w+\?)/)
97
+ if (predicateMatch) {
98
+ guard.requirement = predicateMatch[1]
99
+ } else {
100
+ // Try to extract multi-predicate: "a? || b?"
101
+ const multiMatch = body.match(
102
+ /unless\s+(?:Current\.user\.)?(\w+\?(?:\s*\|\|\s*(?:Current\.user\.)?\w+\?)*)/,
103
+ )
104
+ if (multiMatch)
105
+ guard.requirement = multiMatch[1].replace(/Current\.user\./g, '')
106
+ }
107
+ // Detect what error is raised
108
+ const raiseMatch = body.match(/raise\s+(\w+(?:::\w+)*)/)
109
+ if (raiseMatch) guard.raises = raiseMatch[1]
110
+ concern.guard_methods[name] = guard
111
+ }
112
+ }
113
+
114
+ // Error handling: rescue_from
115
+ const rescueMatch = content.match(
116
+ /rescue_from\s+(\w+(?:::\w+)*),\s*with:\s*:(\w+)/,
117
+ )
118
+ if (rescueMatch) {
119
+ const handlerName = rescueMatch[2]
120
+ const handlerBody = bodies[handlerName] || ''
121
+ const errorHandling = {
122
+ rescue_from: rescueMatch[1],
123
+ handler: handlerName,
124
+ }
125
+ // Detect response logic
126
+ if (/redirect/.test(handlerBody)) {
127
+ const redirectMatch = handlerBody.match(/redirect_to\s+([^\n,]+)/)
128
+ errorHandling.html_response = redirectMatch
129
+ ? `redirect with flash — ${redirectMatch[1].trim()}`
130
+ : 'redirect with flash alert'
131
+ }
132
+ if (/head\s*:forbidden|head\s*403/.test(handlerBody)) {
133
+ errorHandling.non_html_response = 'head :forbidden (HTTP 403)'
134
+ }
135
+ concern.error_handling = errorHandling
136
+ }
137
+
138
+ return concern
139
+ }
140
+
141
+ /** Extract role predicates from User model content. */
142
+ function extractRolePredicates(content) {
143
+ if (!content) return null
144
+
145
+ const bodies = extractMethodBodies(content)
146
+ const atomic = {}
147
+ const composite = {}
148
+ const legacy_aliases = {}
149
+
150
+ for (const [name, body] of Object.entries(bodies)) {
151
+ if (!name.endsWith('?')) continue
152
+ const trimmedBody = body.trim().replace(/\s+/g, ' ')
153
+
154
+ // Detect if this is a simple role check (atomic)
155
+ const singleRoleMatch = trimmedBody.match(/^role\s*==\s*['"](\w+)['"]$/)
156
+ if (singleRoleMatch) {
157
+ atomic[name] = `role == '${singleRoleMatch[1]}'`
158
+ continue
159
+ }
160
+
161
+ // Detect composite predicates (using || or &&)
162
+ if (/\w+\?\s*(\|\||&&)\s*\w+\?/.test(trimmedBody)) {
163
+ composite[name] = trimmedBody
164
+ continue
165
+ }
166
+
167
+ // Detect alias/delegate (method simply calls another predicate)
168
+ const aliasMatch = trimmedBody.match(/^(\w+\?)$/)
169
+ if (aliasMatch && bodies[aliasMatch[1]] !== undefined) {
170
+ legacy_aliases[name] = aliasMatch[1]
171
+ continue
172
+ }
173
+
174
+ // Check for send(:method_name) pattern used by aliases
175
+ const sendMatch = trimmedBody.match(/^send\s*\(\s*:(\w+\?)\s*\)$/)
176
+ if (sendMatch) {
177
+ legacy_aliases[name] = sendMatch[1]
178
+ continue
179
+ }
180
+
181
+ // Simple delegation: method_name → another_method?
182
+ if (/^\w+\?$/.test(trimmedBody)) {
183
+ legacy_aliases[name] = trimmedBody
184
+ }
185
+ }
186
+
187
+ if (
188
+ Object.keys(atomic).length === 0 &&
189
+ Object.keys(composite).length === 0 &&
190
+ Object.keys(legacy_aliases).length === 0
191
+ ) {
192
+ return null
193
+ }
194
+
195
+ return {
196
+ source_file: 'app/models/user.rb',
197
+ atomic,
198
+ composite,
199
+ legacy_aliases:
200
+ Object.keys(legacy_aliases).length > 0 ? legacy_aliases : undefined,
201
+ }
202
+ }
203
+
204
+ /** Extract role definition from User model content. */
205
+ function extractRoleDefinition(content, schemaData) {
206
+ if (!content) return null
207
+
208
+ // Try multiple enum patterns
209
+ const enumPatterns = [
210
+ // Modern: enum :role, { key: 0, ... }
211
+ /enum\s+:role,\s*\{([^}]+)\}/,
212
+ // Legacy: enum role: { key: 0, ... }
213
+ /enum\s+role:\s*\{([^}]+)\}/,
214
+ // Array: enum :role, [ :a, :b ]
215
+ /enum\s+:role,\s*\[([^\]]+)\]/,
216
+ // Legacy array: enum role: [ :a, :b ]
217
+ /enum\s+role:\s*\[([^\]]+)\]/,
218
+ ]
219
+
220
+ let enumBody = null
221
+ let enumType = 'string'
222
+ for (const re of enumPatterns) {
223
+ const m = content.match(re)
224
+ if (m) {
225
+ enumBody = m[1]
226
+ break
227
+ }
228
+ }
229
+
230
+ if (!enumBody) return null
231
+
232
+ // Parse roles from enum body
233
+ const roles = {}
234
+ const pairRe = /(\w+):\s*(\d+)/g
235
+ let pm
236
+ let hasIntValues = false
237
+ while ((pm = pairRe.exec(enumBody))) {
238
+ hasIntValues = true
239
+ roles[pm[1]] = { value: pm[1], default: false }
240
+ }
241
+
242
+ if (!hasIntValues) {
243
+ // Symbol-only enum (string type)
244
+ const symbols =
245
+ enumBody.match(/\w+/g)?.filter((v) => !/^\d+$/.test(v)) || []
246
+ for (const s of symbols) {
247
+ roles[s] = { value: s, default: false }
248
+ }
249
+ } else {
250
+ enumType = 'integer'
251
+ }
252
+
253
+ // Check for default role
254
+ const defaultMatch = content.match(
255
+ /default:\s*['"](\w+)['"]|default.*role.*['"](\w+)['"]/,
256
+ )
257
+ if (defaultMatch) {
258
+ const defaultRole = defaultMatch[1] || defaultMatch[2]
259
+ if (roles[defaultRole]) roles[defaultRole].default = true
260
+ }
261
+
262
+ // Check schema for column details
263
+ let storage = { model: 'User', column: 'role' }
264
+ if (schemaData) {
265
+ const usersTable = (schemaData.tables || []).find((t) => t.name === 'users')
266
+ if (usersTable) {
267
+ const roleCol = usersTable.columns?.find((c) => c.name === 'role')
268
+ if (roleCol) {
269
+ storage.column_type = roleCol.type || 'string'
270
+ if (roleCol.constraints) {
271
+ if (/default/.test(roleCol.constraints))
272
+ storage.default = roleCol.constraints.match(
273
+ /default:\s*['"]?(\w+)['"]?/,
274
+ )?.[1]
275
+ if (/null:\s*false/.test(roleCol.constraints)) storage.null = false
276
+ }
277
+ }
278
+ const roleIndex = usersTable.indexes?.find((i) =>
279
+ i.columns?.includes('role'),
280
+ )
281
+ if (roleIndex) storage.indexed = true
282
+ }
283
+ }
284
+
285
+ // Detect role normalization callbacks
286
+ let normalization = null
287
+ const normMatch = content.match(/before_validation\s+:(\w+)(?:.*?#\s*(.+))?/)
288
+ if (normMatch && /role|legacy/.test(normMatch[1])) {
289
+ normalization = `before_validation :${normMatch[1]}${normMatch[2] ? ' — ' + normMatch[2].trim() : ''}`
290
+ }
291
+
292
+ // Detect legacy role aliases
293
+ const legacy_aliases = {}
294
+ const bodies = extractMethodBodies(content)
295
+ // Look for normalization method that maps old values to new
296
+ for (const [name, body] of Object.entries(bodies)) {
297
+ if (/normalize|legacy|remap/.test(name) && /role/.test(body)) {
298
+ const mappings = body.matchAll(/['"](\w+)['"]\s*=>\s*['"](\w+)['"]/g)
299
+ for (const mapping of mappings) {
300
+ legacy_aliases[mapping[1]] = mapping[2]
301
+ }
302
+ // Also check gsub/sub patterns
303
+ const gsubMatch = body.match(/gsub.*['"](\w+)['"].*['"](\w+)['"]/)
304
+ if (gsubMatch) legacy_aliases[gsubMatch[1]] = gsubMatch[2]
305
+ }
306
+ }
307
+
308
+ return {
309
+ storage,
310
+ enum_type: enumType === 'integer' ? 'integer' : 'string',
311
+ roles,
312
+ legacy_aliases:
313
+ Object.keys(legacy_aliases).length > 0 ? legacy_aliases : undefined,
314
+ normalization,
315
+ }
316
+ }
317
+
318
+ /** Build the controller enforcement map by scanning controller files. */
319
+ function buildEnforcementMap(provider, entries, guardMethodNames) {
320
+ if (guardMethodNames.length === 0) return null
321
+
322
+ const guardPattern = new RegExp(
323
+ `(?:before_action|prepend_before_action)\\s+:?(${guardMethodNames.map((n) => n.replace(/[!?]/g, '\\$&')).join('|')})`,
324
+ )
325
+
326
+ const namespaces = {}
327
+ const unguarded = []
328
+ const controllerGuards = {} // className → { file, guard, superclass }
329
+
330
+ const controllerEntries = entries.filter(
331
+ (e) =>
332
+ e.categoryName === 'controllers' ||
333
+ e.category === 'controller' ||
334
+ (e.path && e.path.includes('app/controllers/') && e.path.endsWith('.rb')),
335
+ )
336
+
337
+ for (const entry of controllerEntries) {
338
+ const content = provider.readFile(entry.path)
339
+ if (!content) continue
340
+
341
+ const classMatch = content.match(
342
+ /class\s+(\w+(?:::\w+)*)\s*<\s*(\w+(?:::\w+)*)/,
343
+ )
344
+ if (!classMatch) continue
345
+
346
+ const className = classMatch[1]
347
+ const superclass = classMatch[2]
348
+
349
+ // Check for guard before_actions
350
+ const guards = []
351
+ const guardRe = new RegExp(guardPattern.source, 'g')
352
+ let gm
353
+ while ((gm = guardRe.exec(content))) {
354
+ const guardName = gm[1]
355
+ // Check for only/except options
356
+ const afterGuard = content.slice(gm.index, gm.index + 200)
357
+ const onlyMatch = afterGuard.match(
358
+ /only:\s*(?:\[([^\]]+)\]|:(\w+)|%i\[([^\]]+)\])/,
359
+ )
360
+ const only = onlyMatch
361
+ ? (onlyMatch[1] || onlyMatch[2] || onlyMatch[3] || '')
362
+ .replace(/[:%]/g, ' ')
363
+ .trim()
364
+ .split(/[\s,]+/)
365
+ .filter(Boolean)
366
+ : null
367
+ guards.push({ method: guardName, only })
368
+ }
369
+
370
+ // Check for allow_unauthenticated_access
371
+ const unauthMatch = content.match(
372
+ /allow_unauthenticated_access(?:\s+only:\s*(?:%i\[([^\]]+)\]|:(\w+)|\[([^\]]+)\]))?/,
373
+ )
374
+
375
+ controllerGuards[className] = {
376
+ file: entry.path,
377
+ guards,
378
+ superclass,
379
+ allow_unauthenticated: !!unauthMatch,
380
+ }
381
+
382
+ if (unauthMatch && guards.length === 0) {
383
+ const only = (unauthMatch[1] || unauthMatch[2] || unauthMatch[3] || '')
384
+ .replace(/[:%]/g, ' ')
385
+ .trim()
386
+ const label = only
387
+ ? `${className} (allow_unauthenticated_access on ${only})`
388
+ : `${className} (allow_unauthenticated_access)`
389
+ unguarded.push(label)
390
+ }
391
+ }
392
+
393
+ // Resolve inheritance: mark controllers that inherit guards
394
+ for (const [className, info] of Object.entries(controllerGuards)) {
395
+ if (info.guards.length === 0 && !info.allow_unauthenticated) {
396
+ // Check if superclass has a guard
397
+ const parent = controllerGuards[info.superclass]
398
+ if (parent && parent.guards.length > 0) {
399
+ info.inherited_guard = {
400
+ from: info.superclass,
401
+ guard: parent.guards[0].method,
402
+ }
403
+ }
404
+ }
405
+ }
406
+
407
+ // Group by namespace
408
+ for (const [className, info] of Object.entries(controllerGuards)) {
409
+ if (info.guards.length === 0 && !info.inherited_guard) continue
410
+
411
+ let ns = 'other'
412
+ if (className.startsWith('Admin::')) ns = 'admin_namespace'
413
+ else if (className.startsWith('Settings::')) ns = 'settings_namespace'
414
+ else if (info.file?.includes('/admin/')) ns = 'admin_namespace'
415
+ else if (info.file?.includes('/settings/')) ns = 'settings_namespace'
416
+ else ns = 'customer_area'
417
+
418
+ if (!namespaces[ns]) namespaces[ns] = { controllers: {} }
419
+
420
+ const ctrlEntry = { file: info.file }
421
+ if (info.guards.length > 0) {
422
+ const primaryGuard = info.guards[0]
423
+ ctrlEntry.guard = primaryGuard.method
424
+ if (primaryGuard.only) ctrlEntry.only = primaryGuard.only.join(', ')
425
+ // Additional guards beyond the first
426
+ if (info.guards.length > 1) {
427
+ ctrlEntry.additional_guards = info.guards.slice(1).map((g) => ({
428
+ guard: g.method,
429
+ only: g.only ? g.only.join(', ') : null,
430
+ }))
431
+ }
432
+ } else if (info.inherited_guard) {
433
+ ctrlEntry.guard = `inherited (${info.inherited_guard.guard})`
434
+ }
435
+
436
+ namespaces[ns].controllers[className] = ctrlEntry
437
+ }
438
+
439
+ // Add base_guard labels for namespaces
440
+ for (const [ns, data] of Object.entries(namespaces)) {
441
+ // Find the base controller for this namespace
442
+ const baseNames = Object.keys(data.controllers).filter(
443
+ (n) => n.endsWith('BaseController') || n === 'Admin::BaseController',
444
+ )
445
+ if (baseNames.length > 0) {
446
+ const baseCtrl = controllerGuards[baseNames[0]]
447
+ if (baseCtrl?.guards?.length > 0) {
448
+ data.base_guard = `${baseCtrl.guards[0].method} (before_action on ${baseNames[0]})`
449
+ }
450
+ }
451
+ }
452
+
453
+ return {
454
+ ...namespaces,
455
+ unguarded_controllers: unguarded.length > 0 ? unguarded : undefined,
456
+ }
457
+ }
458
+
459
+ /** Detect domain roles that are NOT part of the auth system. */
460
+ function detectDomainRoles(provider, entries, authRoleModel) {
461
+ const domainRoles = []
462
+
463
+ // Look for concerns or models with "role" in the name that aren't the auth role model
464
+ for (const entry of entries) {
465
+ if (entry.categoryName !== 'models' && entry.category !== 'model') continue
466
+ if (!/role/i.test(entry.path)) continue
467
+ // Skip the auth role model itself
468
+ if (entry.path === 'app/models/user.rb') continue
469
+
470
+ const content = provider.readFile(entry.path)
471
+ if (!content) continue
472
+
473
+ const isConcern =
474
+ /module\s+\w+/.test(content) &&
475
+ /extend\s+ActiveSupport::Concern/.test(content)
476
+ const classMatch = content.match(/(?:module|class)\s+(\w+(?:::\w+)*)/)
477
+ const name = classMatch ? classMatch[1] : entry.path
478
+
479
+ // Determine purpose from content
480
+ let purpose = 'unknown'
481
+ if (isConcern) {
482
+ // Look for constant arrays or hashes that suggest domain data
483
+ const constMatch = content.match(/(\w+)\s*=\s*(?:\[|%w)/)
484
+ if (constMatch)
485
+ purpose = `Static list defined as ${constMatch[1]} constant`
486
+ else purpose = 'Concern module'
487
+ } else {
488
+ // Check if it's an ActiveRecord model
489
+ if (/class\s+\w+\s*<\s*(?:Application|Active)Record/.test(content)) {
490
+ purpose = 'Domain model for business entities (not access control)'
491
+ }
492
+ }
493
+
494
+ domainRoles.push({
495
+ concern: `${name} (${entry.path})`,
496
+ purpose,
497
+ auth_relevance:
498
+ 'none — purely domain data, not related to access control',
499
+ })
500
+ }
501
+
502
+ return domainRoles.length > 0 ? domainRoles[0] : null
503
+ }
504
+
505
+ /**
506
+ * Extract authorization information.
507
+ * @param {import('../providers/interface.js').FileProvider} provider
508
+ * @param {Array<{path: string, category: string}>} entries
509
+ * @param {{gems?: object}} gemInfo
510
+ * @param {object|null} schemaData
511
+ * @returns {object}
512
+ */
513
+ export function extractAuthorization(
514
+ provider,
515
+ entries,
516
+ gemInfo = {},
517
+ schemaData = null,
518
+ ) {
519
+ const gems = gemInfo.gems || {}
520
+ const result = {
521
+ strategy: null,
522
+ policies: [],
523
+ abilities: null,
524
+ roles: null,
525
+ }
526
+
527
+ const hasPundit = !!gems.pundit
528
+ const hasCanCan = !!gems.cancancan || !!gems.cancan
529
+ const hasActionPolicy = !!gems.action_policy
530
+ const hasRolify = !!gems.rolify
531
+ const hasAccessGranted = !!gems['access-granted']
532
+
533
+ // Report which libraries were searched and not found
534
+ const searchedNotFound = SEARCHED_LIBRARIES.filter((lib) => !gems[lib])
535
+
536
+ // Pundit
537
+ if (hasPundit) {
538
+ result.strategy = 'pundit'
539
+ const policyEntries = entries.filter(
540
+ (e) =>
541
+ e.path.startsWith('app/policies/') && e.path.endsWith('_policy.rb'),
542
+ )
543
+
544
+ for (const entry of policyEntries) {
545
+ const content = provider.readFile(entry.path)
546
+ if (!content) continue
547
+
548
+ const classMatch = content.match(AUTHORIZATION_PATTERNS.policyClass)
549
+ if (!classMatch) continue
550
+
551
+ const policy = {
552
+ class: classMatch[1] + 'Policy',
553
+ resource: classMatch[1],
554
+ permitted_actions: [],
555
+ has_scope: false,
556
+ }
557
+
558
+ const methodRe = new RegExp(
559
+ AUTHORIZATION_PATTERNS.policyMethod.source,
560
+ 'g',
561
+ )
562
+ let m
563
+ while ((m = methodRe.exec(content))) {
564
+ policy.permitted_actions.push(m[1])
565
+ }
566
+
567
+ if (AUTHORIZATION_PATTERNS.policyScopeClass.test(content)) {
568
+ policy.has_scope = true
569
+ }
570
+
571
+ result.policies.push(policy)
572
+ }
573
+ }
574
+
575
+ // CanCanCan
576
+ if (hasCanCan) {
577
+ if (!result.strategy) result.strategy = 'cancancan'
578
+ const abilityContent = provider.readFile('app/models/ability.rb')
579
+ if (
580
+ abilityContent &&
581
+ AUTHORIZATION_PATTERNS.abilityClass.test(abilityContent)
582
+ ) {
583
+ const abilities = []
584
+ const canRe = new RegExp(AUTHORIZATION_PATTERNS.canDef.source, 'gm')
585
+ let m
586
+ while ((m = canRe.exec(abilityContent))) {
587
+ abilities.push({ type: 'can', definition: m[1].trim() })
588
+ }
589
+ const cannotRe = new RegExp(AUTHORIZATION_PATTERNS.cannotDef.source, 'gm')
590
+ while ((m = cannotRe.exec(abilityContent))) {
591
+ abilities.push({ type: 'cannot', definition: m[1].trim() })
592
+ }
593
+ result.abilities = abilities
594
+ }
595
+ }
596
+
597
+ // Action Policy
598
+ if (hasActionPolicy) {
599
+ if (!result.strategy) result.strategy = 'action_policy'
600
+ const policyEntries = entries.filter(
601
+ (e) =>
602
+ e.path.startsWith('app/policies/') && e.path.endsWith('_policy.rb'),
603
+ )
604
+ for (const entry of policyEntries) {
605
+ const content = provider.readFile(entry.path)
606
+ if (!content) continue
607
+ const classMatch = content.match(AUTHORIZATION_PATTERNS.policyClass)
608
+ if (!classMatch) continue
609
+ const policy = {
610
+ class: classMatch[1] + 'Policy',
611
+ resource: classMatch[1],
612
+ permitted_actions: [],
613
+ has_scope: false,
614
+ }
615
+ const methodRe = new RegExp(
616
+ AUTHORIZATION_PATTERNS.policyMethod.source,
617
+ 'g',
618
+ )
619
+ let m
620
+ while ((m = methodRe.exec(content))) {
621
+ policy.permitted_actions.push(m[1])
622
+ }
623
+ result.policies.push(policy)
624
+ }
625
+ }
626
+
627
+ // Rolify
628
+ if (hasRolify) {
629
+ if (!result.strategy) result.strategy = 'rolify'
630
+ }
631
+
632
+ // Custom policies (no gem but app/policies/ exists)
633
+ if (!result.strategy) {
634
+ const policyEntries = entries.filter(
635
+ (e) =>
636
+ e.path.startsWith('app/policies/') && e.path.endsWith('_policy.rb'),
637
+ )
638
+ if (policyEntries.length > 0) {
639
+ result.strategy = 'custom'
640
+ for (const entry of policyEntries) {
641
+ const content = provider.readFile(entry.path)
642
+ if (!content) continue
643
+ const classMatch = content.match(AUTHORIZATION_PATTERNS.policyClass)
644
+ if (classMatch) {
645
+ result.policies.push({
646
+ class: classMatch[1] + 'Policy',
647
+ resource: classMatch[1],
648
+ permitted_actions: [],
649
+ has_scope: false,
650
+ })
651
+ }
652
+ }
653
+ }
654
+ }
655
+
656
+ // Role detection from models
657
+ const modelEntries = entries.filter(
658
+ (e) => e.category === 'model' || e.categoryName === 'models',
659
+ )
660
+ for (const entry of modelEntries) {
661
+ const content = provider.readFile(entry.path)
662
+ if (!content) continue
663
+ if (AUTHORIZATION_PATTERNS.enumRole.test(content)) {
664
+ const className = entry.path
665
+ .split('/')
666
+ .pop()
667
+ .replace('.rb', '')
668
+ .split('_')
669
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
670
+ .join('')
671
+ result.roles = { source: 'enum', model: className }
672
+ break
673
+ }
674
+ }
675
+
676
+ // -------------------------------------------------------
677
+ // Deep custom RBAC extraction (when no standard library found)
678
+ // -------------------------------------------------------
679
+ // Detect authorization concern in controller concerns
680
+ const authzConcernPaths = [
681
+ 'app/controllers/concerns/authorization.rb',
682
+ 'app/controllers/concerns/authorizable.rb',
683
+ ]
684
+ let authzConcernContent = null
685
+ let authzConcernFile = null
686
+ for (const p of authzConcernPaths) {
687
+ const c = provider.readFile(p)
688
+ if (c) {
689
+ authzConcernContent = c
690
+ authzConcernFile = p
691
+ break
692
+ }
693
+ }
694
+ // Fallback: search entries for a concern with "authorization" in path
695
+ if (!authzConcernContent) {
696
+ const concernEntry = entries.find(
697
+ (e) =>
698
+ (e.categoryName === 'controllers' || e.category === 'controller') &&
699
+ e.path.includes('concerns') &&
700
+ e.path.toLowerCase().includes('authoriz'),
701
+ )
702
+ if (concernEntry) {
703
+ authzConcernContent = provider.readFile(concernEntry.path)
704
+ authzConcernFile = concernEntry.path
705
+ }
706
+ }
707
+
708
+ if (authzConcernContent) {
709
+ if (!result.strategy) result.strategy = 'custom_rbac'
710
+
711
+ // Parse the concern
712
+ const concern = parseConcern(authzConcernContent, authzConcernFile)
713
+
714
+ // Check where it's included
715
+ const appCtrlContent = provider.readFile(
716
+ 'app/controllers/application_controller.rb',
717
+ )
718
+ if (appCtrlContent && /include\s+Authorization/.test(appCtrlContent)) {
719
+ concern.included_in =
720
+ 'ApplicationController (via app/controllers/application_controller.rb)'
721
+ }
722
+
723
+ result.concern = concern
724
+
725
+ // Extract role definition and predicates from User model
726
+ const userContent = provider.readFile('app/models/user.rb')
727
+ if (userContent) {
728
+ const roleDefinition = extractRoleDefinition(userContent, schemaData)
729
+ if (roleDefinition) result.role_definition = roleDefinition
730
+
731
+ const predicates = extractRolePredicates(userContent)
732
+ if (predicates) result.predicates = predicates
733
+ }
734
+
735
+ // Build controller enforcement map
736
+ const guardMethodNames = Object.keys(concern.guard_methods || {})
737
+ const enforcementMap = buildEnforcementMap(
738
+ provider,
739
+ entries,
740
+ guardMethodNames,
741
+ )
742
+ if (enforcementMap) result.controller_enforcement_map = enforcementMap
743
+
744
+ // Disambiguate domain roles
745
+ const domainRoles = detectDomainRoles(
746
+ provider,
747
+ entries,
748
+ result.roles?.model,
749
+ )
750
+ if (domainRoles) result.domain_roles_not_auth = domainRoles
751
+
752
+ // Build related files list
753
+ const relatedFiles = [authzConcernFile]
754
+ if (appCtrlContent)
755
+ relatedFiles.push('app/controllers/application_controller.rb')
756
+ if (userContent) relatedFiles.push('app/models/user.rb')
757
+ // Add admin base controller if it exists
758
+ const adminBaseContent = provider.readFile(
759
+ 'app/controllers/admin/base_controller.rb',
760
+ )
761
+ if (adminBaseContent)
762
+ relatedFiles.push('app/controllers/admin/base_controller.rb')
763
+ // Add auth concern for cross-reference
764
+ const authConcernContent = provider.readFile(
765
+ 'app/controllers/concerns/authentication.rb',
766
+ )
767
+ if (authConcernContent)
768
+ relatedFiles.push('app/controllers/concerns/authentication.rb')
769
+ result.related_files = [...new Set(relatedFiles)]
770
+
771
+ // Add description
772
+ if (!hasPundit && !hasCanCan && !hasActionPolicy && !hasRolify) {
773
+ result.description =
774
+ 'Fully custom role-based access control via controller concerns. No Pundit, CanCanCan, or Rolify.'
775
+ result.library = null
776
+ }
777
+ }
778
+
779
+ // Always include searched-and-not-found
780
+ if (searchedNotFound.length > 0) {
781
+ result.searched_libraries_not_found = searchedNotFound
782
+ }
783
+
784
+ return result
785
+ }