@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,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
|
+
}
|