@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,284 @@
1
+ /**
2
+ * API Extractor (#15)
3
+ * Extracts API configuration, serializers, pagination, rate limiting, CORS, GraphQL.
4
+ * Also reports JSON endpoints and Rails-native rate limiting even without a formal API layer.
5
+ */
6
+
7
+ import { API_PATTERNS } from '../core/patterns.js'
8
+
9
+ /**
10
+ * Extract API information.
11
+ * @param {import('../providers/interface.js').FileProvider} provider
12
+ * @param {Array<{path: string, category: string}>} entries
13
+ * @param {{gems?: object}} gemInfo
14
+ * @returns {object}
15
+ */
16
+ export function extractApi(provider, entries, gemInfo = {}) {
17
+ const gems = gemInfo.gems || {}
18
+ const result = {
19
+ api_only: false,
20
+ versioning: [],
21
+ serialization: null,
22
+ pagination: null,
23
+ rate_limiting: null,
24
+ cors: null,
25
+ graphql: null,
26
+ json_endpoints: [],
27
+ summary: null,
28
+ }
29
+
30
+ // API-only mode
31
+ const appContent = provider.readFile('config/application.rb')
32
+ if (appContent && API_PATTERNS.apiOnly.test(appContent)) {
33
+ result.api_only = true
34
+ }
35
+
36
+ // API version namespaces from paths
37
+ const versionSet = new Set()
38
+ for (const entry of entries) {
39
+ const vMatch = entry.path.match(/\/api\/v(\d+)\//)
40
+ if (vMatch) versionSet.add(`v${vMatch[1]}`)
41
+ }
42
+ result.versioning = [...versionSet].sort()
43
+
44
+ // Serialization
45
+ const serializerGem = gems['jsonapi-serializer']
46
+ ? 'jsonapi-serializer'
47
+ : gems.alba
48
+ ? 'alba'
49
+ : gems.blueprinter
50
+ ? 'blueprinter'
51
+ : gems.active_model_serializers
52
+ ? 'active_model_serializers'
53
+ : gems.jbuilder
54
+ ? 'jbuilder'
55
+ : null
56
+
57
+ if (serializerGem) {
58
+ result.serialization = { gem: serializerGem, serializers: [] }
59
+ const serEntries = entries.filter(
60
+ (e) => e.path.includes('serializer') || e.path.includes('blueprint'),
61
+ )
62
+ for (const entry of serEntries) {
63
+ const content = provider.readFile(entry.path)
64
+ if (!content) continue
65
+ const classMatch =
66
+ content.match(API_PATTERNS.serializerClass) ||
67
+ content.match(API_PATTERNS.blueprintClass)
68
+ if (classMatch) {
69
+ const attrs = content.match(API_PATTERNS.serializerAttributes)
70
+ result.serialization.serializers.push({
71
+ class: classMatch[1],
72
+ attributes: attrs ? attrs[1].trim() : null,
73
+ })
74
+ }
75
+ }
76
+ }
77
+
78
+ // Pagination
79
+ const paginationGem = gems.pagy
80
+ ? 'pagy'
81
+ : gems.kaminari
82
+ ? 'kaminari'
83
+ : gems.will_paginate
84
+ ? 'will_paginate'
85
+ : null
86
+ if (paginationGem) {
87
+ result.pagination = { gem: paginationGem }
88
+ }
89
+
90
+ // Rate limiting — rack-attack gem
91
+ const rateLimitThrottles = []
92
+ if (gems['rack-attack']) {
93
+ const rackContent = provider.readFile('config/initializers/rack_attack.rb')
94
+ if (rackContent) {
95
+ const throttleRe = new RegExp(API_PATTERNS.rackAttackThrottle.source, 'g')
96
+ let m
97
+ while ((m = throttleRe.exec(rackContent))) {
98
+ rateLimitThrottles.push(m[1].trim())
99
+ }
100
+ }
101
+ }
102
+
103
+ // Rate limiting — Rails 8 native rate_limit in controllers
104
+ const nativeRateLimits = []
105
+ const controllerEntries = entries.filter(
106
+ (e) => e.categoryName === 'controllers' || e.category === 'controller',
107
+ )
108
+ for (const entry of controllerEntries) {
109
+ const content = provider.readFile(entry.path)
110
+ if (!content) continue
111
+ const ctrlMatch = content.match(/class\s+(\w+(?:::\w+)*)/)
112
+ const ctrlName = ctrlMatch
113
+ ? ctrlMatch[1]
114
+ : entry.path.split('/').pop().replace('.rb', '')
115
+ const rlRe =
116
+ /rate_limit\s+to:\s*(\d+),\s*within:\s*([^,\n]+?)(?:,\s*only:\s*(?:%i\[([^\]]+)\]|:(\w+)|\[([^\]]+)\]))?/gm
117
+ let rl
118
+ while ((rl = rlRe.exec(content))) {
119
+ nativeRateLimits.push({
120
+ controller: ctrlName,
121
+ to: parseInt(rl[1], 10),
122
+ within: rl[2].trim(),
123
+ only: rl[3] || rl[4] || rl[5] || null,
124
+ })
125
+ }
126
+ }
127
+
128
+ if (
129
+ gems['rack-attack'] ||
130
+ nativeRateLimits.length > 0 ||
131
+ rateLimitThrottles.length > 0
132
+ ) {
133
+ result.rate_limiting = {
134
+ gem: gems['rack-attack'] ? 'rack-attack' : null,
135
+ throttles: rateLimitThrottles,
136
+ rails_native: nativeRateLimits.length > 0 ? nativeRateLimits : null,
137
+ }
138
+ } else {
139
+ result.rate_limiting = null
140
+ }
141
+
142
+ // CORS
143
+ const corsContent = provider.readFile('config/initializers/cors.rb')
144
+ if (corsContent && API_PATTERNS.corsConfig.test(corsContent)) {
145
+ result.cors = { origins: [] }
146
+ const originsRe = new RegExp(API_PATTERNS.corsOrigins.source, 'g')
147
+ let m
148
+ while ((m = originsRe.exec(corsContent))) {
149
+ const origins =
150
+ m[1].match(/['"]([^'"]+)['"]/g)?.map((o) => o.replace(/['"]/g, '')) ||
151
+ []
152
+ result.cors.origins.push(...origins)
153
+ }
154
+ }
155
+
156
+ // GraphQL
157
+ if (gems['graphql']) {
158
+ result.graphql = { schema: null, types: [], mutations: [] }
159
+ const graphqlEntries = entries.filter(
160
+ (e) => e.path.startsWith('app/graphql/') && e.path.endsWith('.rb'),
161
+ )
162
+ for (const entry of graphqlEntries) {
163
+ const content = provider.readFile(entry.path)
164
+ if (!content) continue
165
+ const schemaMatch = content.match(API_PATTERNS.graphqlSchema)
166
+ if (schemaMatch) result.graphql.schema = schemaMatch[1]
167
+ const typeRe = new RegExp(API_PATTERNS.graphqlType.source, 'g')
168
+ let m
169
+ while ((m = typeRe.exec(content))) result.graphql.types.push(m[1])
170
+ const mutRe = new RegExp(API_PATTERNS.graphqlMutation.source, 'g')
171
+ while ((m = mutRe.exec(content))) result.graphql.mutations.push(m[1])
172
+ }
173
+ }
174
+
175
+ // JSON endpoints — detect controllers responding to JSON / format.json
176
+ for (const entry of controllerEntries) {
177
+ const content = provider.readFile(entry.path)
178
+ if (!content) continue
179
+ if (
180
+ /respond_to\s*(?:do)?\s*.*json|format\.json|render\s+json:|\.json\b/.test(
181
+ content,
182
+ )
183
+ ) {
184
+ const ctrlMatch = content.match(/class\s+(\w+(?:::\w+)*)/)
185
+ if (ctrlMatch) {
186
+ result.json_endpoints.push({
187
+ controller: ctrlMatch[1],
188
+ file: entry.path,
189
+ note: 'responds to JSON',
190
+ })
191
+ }
192
+ }
193
+ }
194
+
195
+ // Explicit absent flags for common API concerns
196
+ result.api_absent = {
197
+ api_only: !result.api_only,
198
+ versioning: result.versioning.length === 0,
199
+ serializers: !result.serialization,
200
+ cors: !result.cors,
201
+ graphql: !result.graphql,
202
+ pagination: !result.pagination,
203
+ }
204
+
205
+ // Positive search for API authentication patterns — explicit found/not_found
206
+ // Combine all already-read controller content into one string for pattern scanning
207
+ const allControllerContent = controllerEntries
208
+ .map((e) => provider.readFile(e.path) || '')
209
+ .join('\n')
210
+ const gemfileContent = provider.readFile('Gemfile') || ''
211
+ const scanContent = allControllerContent + '\n' + gemfileContent
212
+ const apiAuthPatternChecks = [
213
+ {
214
+ key: 'jwt',
215
+ re: /\bjwt\b|json_web_token|JWT\./i,
216
+ description: 'JWT tokens',
217
+ },
218
+ {
219
+ key: 'api_key',
220
+ re: /api[_\-]key|x-api-key/i,
221
+ description: 'API key header auth',
222
+ },
223
+ {
224
+ key: 'bearer_token',
225
+ re: /\bbearer\b|authenticate_with_http_token/i,
226
+ description: 'Bearer token / HTTP token auth',
227
+ },
228
+ {
229
+ key: 'doorkeeper_oauth',
230
+ re: /doorkeeper|::Doorkeeper/i,
231
+ description: 'Doorkeeper OAuth',
232
+ },
233
+ {
234
+ key: 'devise_jwt',
235
+ re: /devise-jwt|devise\/jwt/i,
236
+ description: 'Devise JWT',
237
+ },
238
+ {
239
+ key: 'token_auth',
240
+ re: /token_authenticatable|has_secure_token\s+:auth/i,
241
+ description: 'Token authenticatable',
242
+ },
243
+ ]
244
+ result.pattern_search_results = apiAuthPatternChecks.map(
245
+ ({ key, re, description }) => ({
246
+ key,
247
+ description,
248
+ found: re.test(scanContent),
249
+ }),
250
+ )
251
+ const anyApiAuth = result.pattern_search_results.some((p) => p.found)
252
+ result.api_auth_present = anyApiAuth
253
+ if (!anyApiAuth) {
254
+ result.api_auth_summary =
255
+ 'No API authentication patterns detected. App uses session-cookie auth only.'
256
+ }
257
+
258
+ // Human-readable summary
259
+ const parts = []
260
+ if (result.api_only) parts.push('API-only Rails app')
261
+ if (result.versioning.length > 0)
262
+ parts.push(`versioned API (${result.versioning.join(', ')})`)
263
+ if (
264
+ !result.api_only &&
265
+ result.json_endpoints.length === 0 &&
266
+ !result.versioning.length
267
+ ) {
268
+ parts.push('No formal API layer')
269
+ }
270
+ if (result.json_endpoints.length > 0) {
271
+ parts.push(`${result.json_endpoints.length} JSON endpoint(s)`)
272
+ }
273
+ if (nativeRateLimits.length > 0) {
274
+ parts.push(
275
+ `Rails 8 native rate limiting on ${[...new Set(nativeRateLimits.map((r) => r.controller))].join(', ')}`,
276
+ )
277
+ }
278
+ if (result.serialization)
279
+ parts.push(`serialization via ${result.serialization.gem}`)
280
+ if (result.graphql) parts.push('GraphQL API')
281
+ result.summary = parts.join('. ') + '.'
282
+
283
+ return result
284
+ }