@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,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 3: Structural Scanner
|
|
3
|
+
*
|
|
4
|
+
* Classifies all project files by path into the 56-category taxonomy.
|
|
5
|
+
* Zero file content reads — pure path-based classification using
|
|
6
|
+
* provider.glob() and provider.listDir().
|
|
7
|
+
*
|
|
8
|
+
* @module scanner
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {Object} ManifestEntry
|
|
13
|
+
* @property {string} path - Relative file path
|
|
14
|
+
* @property {number} category - Category number (1-56)
|
|
15
|
+
* @property {string} categoryName - Human-readable category name
|
|
16
|
+
* @property {string} type - File type (ruby, js, erb, yml, etc.)
|
|
17
|
+
* @property {string|null} [specCategory] - Sub-category for test files (category 19 only)
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {Object} Manifest
|
|
22
|
+
* @property {ManifestEntry[]} entries - All classified files
|
|
23
|
+
* @property {Object<string, ManifestEntry[]>} byCategory - Entries grouped by category name
|
|
24
|
+
* @property {Object} stats - File counts per category
|
|
25
|
+
* @property {string[]} unclassified - Files that didn't match any rule
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** Category name constants */
|
|
29
|
+
const CATEGORIES = {
|
|
30
|
+
1: 'models',
|
|
31
|
+
2: 'controllers',
|
|
32
|
+
3: 'routes',
|
|
33
|
+
4: 'schema',
|
|
34
|
+
5: 'components',
|
|
35
|
+
6: 'stimulus',
|
|
36
|
+
7: 'views',
|
|
37
|
+
8: 'authentication',
|
|
38
|
+
9: 'authorization',
|
|
39
|
+
10: 'jobs',
|
|
40
|
+
11: 'email',
|
|
41
|
+
12: 'storage',
|
|
42
|
+
13: 'caching',
|
|
43
|
+
14: 'realtime',
|
|
44
|
+
15: 'api',
|
|
45
|
+
16: 'gemfile',
|
|
46
|
+
17: 'config',
|
|
47
|
+
18: 'security',
|
|
48
|
+
19: 'testing',
|
|
49
|
+
20: 'code_quality',
|
|
50
|
+
21: 'deployment',
|
|
51
|
+
22: 'search',
|
|
52
|
+
23: 'payments',
|
|
53
|
+
24: 'multi_tenancy',
|
|
54
|
+
25: 'admin',
|
|
55
|
+
26: 'design_patterns',
|
|
56
|
+
27: 'state_machines',
|
|
57
|
+
28: 'i18n',
|
|
58
|
+
29: 'pdf',
|
|
59
|
+
30: 'csv',
|
|
60
|
+
31: 'webhooks',
|
|
61
|
+
32: 'scheduled_tasks',
|
|
62
|
+
33: 'middleware',
|
|
63
|
+
34: 'engines',
|
|
64
|
+
35: 'credentials',
|
|
65
|
+
36: 'http_client',
|
|
66
|
+
37: 'performance',
|
|
67
|
+
38: 'database_tooling',
|
|
68
|
+
39: 'rich_text',
|
|
69
|
+
40: 'notifications',
|
|
70
|
+
41: 'feature_flags',
|
|
71
|
+
42: 'audit',
|
|
72
|
+
43: 'soft_delete',
|
|
73
|
+
44: 'pagination',
|
|
74
|
+
45: 'friendly_urls',
|
|
75
|
+
46: 'tagging',
|
|
76
|
+
47: 'seo',
|
|
77
|
+
48: 'geolocation',
|
|
78
|
+
49: 'sms_push',
|
|
79
|
+
50: 'activity_tracking',
|
|
80
|
+
51: 'data_import_export',
|
|
81
|
+
52: 'event_sourcing',
|
|
82
|
+
53: 'dry_rb',
|
|
83
|
+
54: 'markdown',
|
|
84
|
+
55: 'rate_limiting',
|
|
85
|
+
56: 'graphql',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Classification rules. Each rule maps a path pattern to a category.
|
|
90
|
+
* Order matters — first match wins for ambiguous paths.
|
|
91
|
+
*
|
|
92
|
+
* The rules are ordered by specificity:
|
|
93
|
+
* 1. Singleton files (routes.rb, schema.rb, Gemfile) — exact path matches
|
|
94
|
+
* 2. Auth-specific controllers (sessions, registrations, passwords) — before
|
|
95
|
+
* general controllers so they land in category 8 instead of category 2
|
|
96
|
+
* 3. Specialized app/ subdirectories (policies, serializers, services) —
|
|
97
|
+
* before general models/controllers to capture design-pattern files
|
|
98
|
+
* 4. Core Tier 1 categories (models, controllers, views, jobs, etc.)
|
|
99
|
+
* 5. Config and infrastructure files
|
|
100
|
+
* 6. Catch-all patterns (lib/, migrations)
|
|
101
|
+
*
|
|
102
|
+
* @type {Array<{test: function(string): boolean, category: number}>}
|
|
103
|
+
*/
|
|
104
|
+
const RULES = [
|
|
105
|
+
// --- Singleton files: exact path matches first ---
|
|
106
|
+
{ test: (p) => /^config\/routes(\.rb|\/.*\.rb)$/.test(p), category: 3 },
|
|
107
|
+
{ test: (p) => /^db\/(schema\.rb|structure\.sql)$/.test(p), category: 4 },
|
|
108
|
+
{ test: (p) => p === 'Gemfile' || p === 'Gemfile.lock', category: 16 },
|
|
109
|
+
|
|
110
|
+
// --- Auth-specific files: must precede general controllers/models ---
|
|
111
|
+
// Devise initializer is config but functionally auth
|
|
112
|
+
{ test: (p) => /^config\/initializers\/devise\.rb$/.test(p), category: 8 },
|
|
113
|
+
// Rails 8 native auth uses Session/Current models
|
|
114
|
+
{ test: (p) => /^app\/models\/(session|current)\.rb$/.test(p), category: 8 },
|
|
115
|
+
// Auth controllers: sessions, registrations, passwords, confirmations
|
|
116
|
+
{
|
|
117
|
+
test: (p) => /^app\/controllers\/.*sessions_controller\.rb$/.test(p),
|
|
118
|
+
category: 8,
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
test: (p) => /^app\/controllers\/.*registrations_controller\.rb$/.test(p),
|
|
122
|
+
category: 8,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
test: (p) => /^app\/controllers\/.*passwords_controller\.rb$/.test(p),
|
|
126
|
+
category: 8,
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
test: (p) => /^app\/controllers\/.*confirmations_controller\.rb$/.test(p),
|
|
130
|
+
category: 8,
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
// --- Authorization: policies and ability files ---
|
|
134
|
+
{ test: (p) => /^app\/policies\/.*\.rb$/.test(p), category: 9 },
|
|
135
|
+
{ test: (p) => /^app\/models\/ability\.rb$/.test(p), category: 9 },
|
|
136
|
+
|
|
137
|
+
// --- API serializers/blueprints: before general models ---
|
|
138
|
+
{ test: (p) => /^app\/serializers\/.*\.rb$/.test(p), category: 15 },
|
|
139
|
+
{ test: (p) => /^app\/blueprints\/.*\.rb$/.test(p), category: 15 },
|
|
140
|
+
|
|
141
|
+
// --- GraphQL: own directory under app/ ---
|
|
142
|
+
{ test: (p) => /^app\/graphql\/.*\.rb$/.test(p), category: 56 },
|
|
143
|
+
|
|
144
|
+
// --- Design pattern directories: before general models ---
|
|
145
|
+
{ test: (p) => /^app\/services\/.*\.rb$/.test(p), category: 26 },
|
|
146
|
+
{ test: (p) => /^app\/forms\/.*\.rb$/.test(p), category: 26 },
|
|
147
|
+
{ test: (p) => /^app\/queries\/.*\.rb$/.test(p), category: 26 },
|
|
148
|
+
{ test: (p) => /^app\/decorators\/.*\.rb$/.test(p), category: 26 },
|
|
149
|
+
{ test: (p) => /^app\/presenters\/.*\.rb$/.test(p), category: 26 },
|
|
150
|
+
{ test: (p) => /^app\/interactors\/.*\.rb$/.test(p), category: 26 },
|
|
151
|
+
|
|
152
|
+
// --- Admin namespace ---
|
|
153
|
+
{ test: (p) => /^app\/admin\/.*\.rb$/.test(p), category: 25 },
|
|
154
|
+
|
|
155
|
+
// --- Workers: Sidekiq native workers (before general models) ---
|
|
156
|
+
{ test: (p) => /^app\/workers\/.*\.rb$/.test(p), category: 10 },
|
|
157
|
+
{ test: (p) => /^app\/sidekiq\/.*\.rb$/.test(p), category: 10 },
|
|
158
|
+
|
|
159
|
+
// --- Uploaders: CarrierWave / Shrine (before general models) ---
|
|
160
|
+
{ test: (p) => /^app\/uploaders\/.*\.rb$/.test(p), category: 12 },
|
|
161
|
+
|
|
162
|
+
// --- Validators: Custom validators (before general models) ---
|
|
163
|
+
{ test: (p) => /^app\/validators\/.*\.rb$/.test(p), category: 26 },
|
|
164
|
+
|
|
165
|
+
// --- Notifiers: Action Notifier (future, before general models) ---
|
|
166
|
+
{ test: (p) => /^app\/notifiers\/.*\.rb$/.test(p), category: 40 },
|
|
167
|
+
|
|
168
|
+
// --- Core Tier 1: broad app/ directory matches ---
|
|
169
|
+
{ test: (p) => /^app\/models\/.*\.rb$/.test(p), category: 1 },
|
|
170
|
+
{ test: (p) => /^app\/controllers\/.*\.rb$/.test(p), category: 2 },
|
|
171
|
+
{ test: (p) => /^app\/components\/.*\.(rb|html\.\w+)$/.test(p), category: 5 },
|
|
172
|
+
{
|
|
173
|
+
test: (p) => /^app\/javascript\/controllers\/.*\.js$/.test(p),
|
|
174
|
+
category: 6,
|
|
175
|
+
},
|
|
176
|
+
{ test: (p) => /^app\/views\/.*/.test(p), category: 7 },
|
|
177
|
+
// --- Helpers: View helpers ---
|
|
178
|
+
{ test: (p) => /^app\/helpers\/.*\.rb$/.test(p), category: 7 },
|
|
179
|
+
{ test: (p) => /^app\/jobs\/.*\.rb$/.test(p), category: 10 },
|
|
180
|
+
{ test: (p) => /^app\/mailers\/.*\.rb$/.test(p), category: 11 },
|
|
181
|
+
{ test: (p) => /^app\/channels\/.*\.rb$/.test(p), category: 14 },
|
|
182
|
+
{ test: (p) => /^app\/mailboxes\/.*\.rb$/.test(p), category: 11 },
|
|
183
|
+
|
|
184
|
+
// --- Infrastructure & Config ---
|
|
185
|
+
{ test: (p) => /^config\/storage\.yml$/.test(p), category: 12 },
|
|
186
|
+
{ test: (p) => /^config\/application\.rb$/.test(p), category: 17 },
|
|
187
|
+
{ test: (p) => /^config\/environments\/.*\.rb$/.test(p), category: 17 },
|
|
188
|
+
{ test: (p) => /^config\/database\.yml$/.test(p), category: 17 },
|
|
189
|
+
{ test: (p) => /^config\/cable\.yml$/.test(p), category: 14 },
|
|
190
|
+
{ test: (p) => /^config\/initializers\/.*\.rb$/.test(p), category: 17 },
|
|
191
|
+
|
|
192
|
+
// Security-specific initializers (after general initializers to override)
|
|
193
|
+
{
|
|
194
|
+
test: (p) => /^config\/initializers\/content_security_policy\.rb$/.test(p),
|
|
195
|
+
category: 18,
|
|
196
|
+
},
|
|
197
|
+
{ test: (p) => /^config\/initializers\/cors\.rb$/.test(p), category: 18 },
|
|
198
|
+
|
|
199
|
+
// --- Testing ---
|
|
200
|
+
{ test: (p) => /^spec\/.*\.rb$/.test(p), category: 19 },
|
|
201
|
+
{ test: (p) => /^test\/.*\.rb$/.test(p), category: 19 },
|
|
202
|
+
{ test: (p) => /^\.rspec$/.test(p), category: 19 },
|
|
203
|
+
|
|
204
|
+
// --- Code quality & tooling ---
|
|
205
|
+
{ test: (p) => /^\.rubocop(\.yml|_todo\.yml)$/.test(p), category: 20 },
|
|
206
|
+
{ test: (p) => /^\.eslintrc/.test(p), category: 20 },
|
|
207
|
+
|
|
208
|
+
// --- Deployment ---
|
|
209
|
+
{
|
|
210
|
+
test: (p) => /^(Dockerfile|docker-compose\.yml|\.dockerignore)$/.test(p),
|
|
211
|
+
category: 21,
|
|
212
|
+
},
|
|
213
|
+
{ test: (p) => /^config\/deploy\.yml$/.test(p), category: 21 },
|
|
214
|
+
{ test: (p) => /^\.kamal\/.*/.test(p), category: 21 },
|
|
215
|
+
{ test: (p) => /^config\/deploy\/.*\.rb$/.test(p), category: 21 },
|
|
216
|
+
{ test: (p) => /^Procfile/.test(p), category: 21 },
|
|
217
|
+
{ test: (p) => /^config\/puma\.rb$/.test(p), category: 21 },
|
|
218
|
+
|
|
219
|
+
// --- i18n, credentials, middleware, engines, notifications ---
|
|
220
|
+
{ test: (p) => /^config\/locales\/.*\.yml$/.test(p), category: 28 },
|
|
221
|
+
{ test: (p) => /^config\/credentials/.test(p), category: 35 },
|
|
222
|
+
{ test: (p) => /^config\/master\.key$/.test(p), category: 35 },
|
|
223
|
+
{ test: (p) => /^\.env/.test(p), category: 35 },
|
|
224
|
+
{ test: (p) => /^app\/middleware\/.*\.rb$/.test(p), category: 33 },
|
|
225
|
+
{ test: (p) => /^engines\/.*/.test(p), category: 34 },
|
|
226
|
+
{ test: (p) => /^lib\/engines\/.*/.test(p), category: 34 },
|
|
227
|
+
{ test: (p) => /^app\/notifications\/.*\.rb$/.test(p), category: 40 },
|
|
228
|
+
|
|
229
|
+
// --- Catch-all: lib and migrations ---
|
|
230
|
+
{ test: (p) => /^lib\/.*\.rb$/.test(p), category: 17 },
|
|
231
|
+
{ test: (p) => /^db\/migrate\/.*\.rb$/.test(p), category: 4 },
|
|
232
|
+
{ test: (p) => /^db\/seeds\.rb$/.test(p), category: 17 },
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Detect file type from extension.
|
|
237
|
+
* @param {string} path
|
|
238
|
+
* @returns {string}
|
|
239
|
+
*/
|
|
240
|
+
function detectType(path) {
|
|
241
|
+
if (path.endsWith('.rb')) return 'ruby'
|
|
242
|
+
if (path.endsWith('.js')) return 'javascript'
|
|
243
|
+
if (path.endsWith('.ts')) return 'typescript'
|
|
244
|
+
if (path.endsWith('.html.erb')) return 'erb'
|
|
245
|
+
if (path.endsWith('.html.haml')) return 'haml'
|
|
246
|
+
if (path.endsWith('.html.slim')) return 'slim'
|
|
247
|
+
if (path.endsWith('.jbuilder')) return 'jbuilder'
|
|
248
|
+
if (path.endsWith('.yml') || path.endsWith('.yaml')) return 'yaml'
|
|
249
|
+
if (path.endsWith('.json.erb')) return 'json_erb'
|
|
250
|
+
if (path.endsWith('.json')) return 'json'
|
|
251
|
+
if (path.endsWith('.sql')) return 'sql'
|
|
252
|
+
if (path.endsWith('.css')) return 'css'
|
|
253
|
+
if (path.endsWith('.scss')) return 'scss'
|
|
254
|
+
return 'other'
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Classify a single file path into a category.
|
|
259
|
+
* @param {string} path - Relative file path
|
|
260
|
+
* @returns {ManifestEntry|null}
|
|
261
|
+
*/
|
|
262
|
+
function classifyFile(path) {
|
|
263
|
+
for (const rule of RULES) {
|
|
264
|
+
if (rule.test(path)) {
|
|
265
|
+
const entry = {
|
|
266
|
+
path,
|
|
267
|
+
category: rule.category,
|
|
268
|
+
categoryName: CATEGORIES[rule.category],
|
|
269
|
+
type: detectType(path),
|
|
270
|
+
}
|
|
271
|
+
if (entry.category === 19) {
|
|
272
|
+
entry.specCategory = classifySpecFile(path)
|
|
273
|
+
}
|
|
274
|
+
if (entry.category === 7 && path.startsWith('app/views/pwa/')) {
|
|
275
|
+
entry.pwaFile = true
|
|
276
|
+
}
|
|
277
|
+
if (
|
|
278
|
+
entry.category === 10 &&
|
|
279
|
+
(path.startsWith('app/workers/') || path.startsWith('app/sidekiq/'))
|
|
280
|
+
) {
|
|
281
|
+
entry.workerType = 'sidekiq_native'
|
|
282
|
+
}
|
|
283
|
+
return entry
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return null
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Sub-classify a spec/test file by its directory.
|
|
291
|
+
* @param {string} path
|
|
292
|
+
* @returns {string|null}
|
|
293
|
+
*/
|
|
294
|
+
function classifySpecFile(path) {
|
|
295
|
+
if (path.startsWith('spec/models/')) return 'model_specs'
|
|
296
|
+
if (path.startsWith('spec/requests/')) return 'request_specs'
|
|
297
|
+
if (path.startsWith('spec/controllers/')) return 'controller_specs'
|
|
298
|
+
if (path.startsWith('spec/services/')) return 'service_specs'
|
|
299
|
+
if (path.startsWith('spec/jobs/')) return 'job_specs'
|
|
300
|
+
if (path.startsWith('spec/mailers/')) return 'mailer_specs'
|
|
301
|
+
if (path.startsWith('spec/policies/')) return 'policy_specs'
|
|
302
|
+
if (path.startsWith('spec/components/')) return 'component_specs'
|
|
303
|
+
if (path.startsWith('spec/forms/')) return 'form_specs'
|
|
304
|
+
if (path.startsWith('spec/factories/')) return 'factories'
|
|
305
|
+
if (path.startsWith('spec/support/')) return 'support'
|
|
306
|
+
if (path.startsWith('spec/shared_examples/')) return 'shared_examples'
|
|
307
|
+
if (path.startsWith('spec/shared_contexts/')) return 'shared_contexts'
|
|
308
|
+
if (path.startsWith('test/models/')) return 'model_tests'
|
|
309
|
+
if (path.startsWith('test/controllers/')) return 'controller_tests'
|
|
310
|
+
if (path.startsWith('test/integration/')) return 'integration_tests'
|
|
311
|
+
if (path.startsWith('test/factories/')) return 'factories'
|
|
312
|
+
return null
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Scan the project structure and classify all files.
|
|
317
|
+
* Zero file content reads — uses only glob and listDir.
|
|
318
|
+
*
|
|
319
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
320
|
+
* @returns {Manifest}
|
|
321
|
+
*/
|
|
322
|
+
export function scanStructure(provider) {
|
|
323
|
+
const entries = []
|
|
324
|
+
const unclassified = []
|
|
325
|
+
const byCategory = {}
|
|
326
|
+
const stats = {}
|
|
327
|
+
|
|
328
|
+
// Initialize byCategory
|
|
329
|
+
for (const [num, name] of Object.entries(CATEGORIES)) {
|
|
330
|
+
byCategory[name] = []
|
|
331
|
+
stats[name] = 0
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Get all relevant files
|
|
335
|
+
const allFiles = [
|
|
336
|
+
...provider.glob('app/**/*.rb'),
|
|
337
|
+
...provider.glob('app/**/*.js'),
|
|
338
|
+
...provider.glob('app/**/*.ts'),
|
|
339
|
+
...provider.glob('app/**/*.html.erb'),
|
|
340
|
+
...provider.glob('app/**/*.json.erb'),
|
|
341
|
+
...provider.glob('app/**/*.html.haml'),
|
|
342
|
+
...provider.glob('app/**/*.html.slim'),
|
|
343
|
+
...provider.glob('app/**/*.jbuilder'),
|
|
344
|
+
...provider.glob('config/**/*.rb'),
|
|
345
|
+
...provider.glob('config/**/*.yml'),
|
|
346
|
+
...provider.glob('db/**/*.rb'),
|
|
347
|
+
...provider.glob('db/**/*.sql'),
|
|
348
|
+
...provider.glob('lib/**/*.rb'),
|
|
349
|
+
...provider.glob('spec/**/*.rb'),
|
|
350
|
+
...provider.glob('test/**/*.rb'),
|
|
351
|
+
...provider.glob('engines/**/*'),
|
|
352
|
+
]
|
|
353
|
+
|
|
354
|
+
// Add specific files
|
|
355
|
+
const specificFiles = [
|
|
356
|
+
'Gemfile',
|
|
357
|
+
'Gemfile.lock',
|
|
358
|
+
'Dockerfile',
|
|
359
|
+
'docker-compose.yml',
|
|
360
|
+
'.dockerignore',
|
|
361
|
+
'Procfile',
|
|
362
|
+
'.rspec',
|
|
363
|
+
'.rubocop.yml',
|
|
364
|
+
'.rubocop_todo.yml',
|
|
365
|
+
'.env',
|
|
366
|
+
'.env.development',
|
|
367
|
+
'.env.production',
|
|
368
|
+
]
|
|
369
|
+
|
|
370
|
+
for (const file of specificFiles) {
|
|
371
|
+
if (provider.fileExists(file)) {
|
|
372
|
+
allFiles.push(file)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Deduplicate
|
|
377
|
+
const uniqueFiles = [...new Set(allFiles)]
|
|
378
|
+
|
|
379
|
+
// Classify each file
|
|
380
|
+
for (const filePath of uniqueFiles) {
|
|
381
|
+
const entry = classifyFile(filePath)
|
|
382
|
+
if (entry) {
|
|
383
|
+
entries.push(entry)
|
|
384
|
+
byCategory[entry.categoryName].push(entry)
|
|
385
|
+
stats[entry.categoryName]++
|
|
386
|
+
} else {
|
|
387
|
+
unclassified.push(filePath)
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return { entries, byCategory, stats, unclassified }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export { classifyFile, classifySpecFile, CATEGORIES }
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layer 2: Version Detector
|
|
3
|
+
*
|
|
4
|
+
* Detects Rails version, Ruby version, and key framework versions from
|
|
5
|
+
* Gemfile, Gemfile.lock, config/application.rb, and related config files.
|
|
6
|
+
* Uses FileProvider for all reads.
|
|
7
|
+
*
|
|
8
|
+
* @module version-detector
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { GEMFILE_PATTERNS, CONFIG_PATTERNS } from './patterns.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {Object} VersionInfo
|
|
15
|
+
* @property {string|null} rails - Rails version (e.g., "7.1.3")
|
|
16
|
+
* @property {string|null} ruby - Ruby version (e.g., "3.2.2")
|
|
17
|
+
* @property {string|null} loadDefaults - config.load_defaults value
|
|
18
|
+
* @property {Object} framework - Detected framework stack
|
|
19
|
+
* @property {string|null} framework.assetPipeline - "sprockets" | "propshaft" | null
|
|
20
|
+
* @property {string|null} framework.jsBundling - "webpacker" | "importmap" | "esbuild" | "rollup" | "webpack" | null
|
|
21
|
+
* @property {string|null} framework.cssBundling - "tailwind" | "bootstrap" | "sass" | "postcss" | null
|
|
22
|
+
* @property {string|null} framework.auth - "devise" | "native" | null
|
|
23
|
+
* @property {string|null} framework.jobAdapter - "sidekiq" | "solid_queue" | "good_job" | "delayed_job" | "async" | null
|
|
24
|
+
* @property {string|null} framework.cacheStore - "redis" | "solid_cache" | "memcached" | "memory" | null
|
|
25
|
+
* @property {string|null} framework.cableAdapter - "redis" | "solid_cable" | "async" | null
|
|
26
|
+
* @property {string|null} framework.testFramework - "rspec" | "minitest" | null
|
|
27
|
+
* @property {string|null} framework.deploy - "kamal" | "capistrano" | "heroku" | "docker" | null
|
|
28
|
+
* @property {boolean} framework.hotwire - Whether Hotwire (Turbo + Stimulus) is present
|
|
29
|
+
* @property {boolean} framework.apiOnly - Whether app is API-only
|
|
30
|
+
* @property {Object} gems - Key gem versions keyed by name
|
|
31
|
+
* @property {string[]} warnings - Detection warnings
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Detect versions and framework stack from project files.
|
|
36
|
+
*
|
|
37
|
+
* @param {import('../providers/interface.js').FileProvider} provider - File access provider
|
|
38
|
+
* @returns {VersionInfo}
|
|
39
|
+
*/
|
|
40
|
+
export function detectVersions(provider) {
|
|
41
|
+
const warnings = []
|
|
42
|
+
const gems = {}
|
|
43
|
+
|
|
44
|
+
// Read key files
|
|
45
|
+
const gemfile = provider.readFile('Gemfile') || ''
|
|
46
|
+
const gemfileLock = provider.readFile('Gemfile.lock') || ''
|
|
47
|
+
const appConfig = provider.readFile('config/application.rb') || ''
|
|
48
|
+
|
|
49
|
+
// Extract Rails version
|
|
50
|
+
const rails = extractRailsVersion(gemfile, gemfileLock)
|
|
51
|
+
if (!rails) warnings.push('Could not determine Rails version')
|
|
52
|
+
|
|
53
|
+
// Extract Ruby version
|
|
54
|
+
const ruby = extractRubyVersion(gemfile, gemfileLock, provider)
|
|
55
|
+
if (!ruby) warnings.push('Could not determine Ruby version')
|
|
56
|
+
|
|
57
|
+
// Extract load_defaults
|
|
58
|
+
const loadDefaults = extractLoadDefaults(appConfig)
|
|
59
|
+
|
|
60
|
+
// Parse all gems from Gemfile
|
|
61
|
+
parseGems(gemfile, gems)
|
|
62
|
+
|
|
63
|
+
// Parse precise versions from Gemfile.lock
|
|
64
|
+
parseLockfileVersions(gemfileLock, gems)
|
|
65
|
+
|
|
66
|
+
// Detect framework stack
|
|
67
|
+
const framework = detectFramework(gemfile, gems, appConfig, provider)
|
|
68
|
+
|
|
69
|
+
return { rails, ruby, loadDefaults, framework, gems, warnings }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Extract Rails version from Gemfile and Gemfile.lock.
|
|
74
|
+
* @param {string} gemfile
|
|
75
|
+
* @param {string} gemfileLock
|
|
76
|
+
* @returns {string|null}
|
|
77
|
+
*/
|
|
78
|
+
function extractRailsVersion(gemfile, gemfileLock) {
|
|
79
|
+
// Try Gemfile.lock first (most precise)
|
|
80
|
+
const lockMatch = gemfileLock.match(
|
|
81
|
+
/^\s+rails\s+\((\d+\.\d+\.\d+(?:\.\w+)?)\)/m,
|
|
82
|
+
)
|
|
83
|
+
if (lockMatch) return lockMatch[1]
|
|
84
|
+
|
|
85
|
+
// Try Gemfile
|
|
86
|
+
const gemMatch = gemfile.match(
|
|
87
|
+
/gem\s+['"]rails['"],\s*['"]~?\s*>?\s*=?\s*(\d+\.\d+(?:\.\d+)?)['"]/,
|
|
88
|
+
)
|
|
89
|
+
if (gemMatch) return gemMatch[1]
|
|
90
|
+
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract Ruby version from Gemfile, Gemfile.lock, or .ruby-version.
|
|
96
|
+
* @param {string} gemfile
|
|
97
|
+
* @param {string} gemfileLock
|
|
98
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
99
|
+
* @returns {string|null}
|
|
100
|
+
*/
|
|
101
|
+
function extractRubyVersion(gemfile, gemfileLock, provider) {
|
|
102
|
+
// Gemfile.lock RUBY VERSION section
|
|
103
|
+
const lockMatch = gemfileLock.match(/RUBY VERSION\s+ruby\s+(\d+\.\d+\.\d+)/)
|
|
104
|
+
if (lockMatch) return lockMatch[1]
|
|
105
|
+
|
|
106
|
+
// Gemfile ruby declaration
|
|
107
|
+
const gemMatch = gemfile.match(GEMFILE_PATTERNS.ruby)
|
|
108
|
+
if (gemMatch) return gemMatch[1]
|
|
109
|
+
|
|
110
|
+
// .ruby-version file
|
|
111
|
+
const rubyVersion = provider.readFile('.ruby-version')
|
|
112
|
+
if (rubyVersion) {
|
|
113
|
+
const ver = rubyVersion.trim().match(/^(\d+\.\d+\.\d+)/)
|
|
114
|
+
if (ver) return ver[1]
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Extract config.load_defaults from application.rb.
|
|
122
|
+
* @param {string} appConfig
|
|
123
|
+
* @returns {string|null}
|
|
124
|
+
*/
|
|
125
|
+
function extractLoadDefaults(appConfig) {
|
|
126
|
+
const m = appConfig.match(CONFIG_PATTERNS.loadDefaults)
|
|
127
|
+
return m ? m[1] : null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Parse gem names from Gemfile.
|
|
132
|
+
* @param {string} gemfile
|
|
133
|
+
* @param {Object} gems
|
|
134
|
+
*/
|
|
135
|
+
function parseGems(gemfile, gems) {
|
|
136
|
+
const lines = gemfile.split('\n')
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
const m = line.match(GEMFILE_PATTERNS.gem)
|
|
139
|
+
if (m) {
|
|
140
|
+
const name = m[1]
|
|
141
|
+
const version = m[2] || null
|
|
142
|
+
gems[name] = { declared: version }
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Parse precise versions from Gemfile.lock.
|
|
149
|
+
* @param {string} gemfileLock
|
|
150
|
+
* @param {Object} gems
|
|
151
|
+
*/
|
|
152
|
+
function parseLockfileVersions(gemfileLock, gems) {
|
|
153
|
+
const lines = gemfileLock.split('\n')
|
|
154
|
+
let inSpecs = false
|
|
155
|
+
|
|
156
|
+
for (const line of lines) {
|
|
157
|
+
if (line.trim() === 'specs:') {
|
|
158
|
+
inSpecs = true
|
|
159
|
+
continue
|
|
160
|
+
}
|
|
161
|
+
if (inSpecs && /^\S/.test(line)) {
|
|
162
|
+
inSpecs = false
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
if (!inSpecs) continue
|
|
166
|
+
|
|
167
|
+
const m = line.match(/^\s{4}(\S+)\s+\((\d+\.\d+(?:\.\d+(?:\.\w+)?)?)\)/)
|
|
168
|
+
if (m) {
|
|
169
|
+
const name = m[1]
|
|
170
|
+
const version = m[2]
|
|
171
|
+
if (gems[name]) {
|
|
172
|
+
gems[name].locked = version
|
|
173
|
+
} else {
|
|
174
|
+
gems[name] = { locked: version }
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Detect framework stack from gem presence and config.
|
|
182
|
+
* @param {string} gemfile
|
|
183
|
+
* @param {Object} gems
|
|
184
|
+
* @param {string} appConfig
|
|
185
|
+
* @param {import('../providers/interface.js').FileProvider} provider
|
|
186
|
+
* @returns {Object}
|
|
187
|
+
*/
|
|
188
|
+
function detectFramework(gemfile, gems, appConfig, provider) {
|
|
189
|
+
const hasGem = (name) => name in gems
|
|
190
|
+
|
|
191
|
+
// Asset pipeline
|
|
192
|
+
let assetPipeline = null
|
|
193
|
+
if (hasGem('propshaft')) assetPipeline = 'propshaft'
|
|
194
|
+
else if (hasGem('sprockets') || hasGem('sprockets-rails'))
|
|
195
|
+
assetPipeline = 'sprockets'
|
|
196
|
+
|
|
197
|
+
// JS bundling
|
|
198
|
+
let jsBundling = null
|
|
199
|
+
if (hasGem('webpacker')) jsBundling = 'webpacker'
|
|
200
|
+
else if (hasGem('importmap-rails')) jsBundling = 'importmap'
|
|
201
|
+
else if (hasGem('jsbundling-rails')) {
|
|
202
|
+
// Check package.json for specific bundler
|
|
203
|
+
const pkgJson = provider.readFile('package.json')
|
|
204
|
+
if (pkgJson) {
|
|
205
|
+
if (pkgJson.includes('"esbuild"')) jsBundling = 'esbuild'
|
|
206
|
+
else if (pkgJson.includes('"rollup"')) jsBundling = 'rollup'
|
|
207
|
+
else if (pkgJson.includes('"webpack"')) jsBundling = 'webpack'
|
|
208
|
+
else jsBundling = 'jsbundling'
|
|
209
|
+
} else {
|
|
210
|
+
jsBundling = 'jsbundling'
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// CSS
|
|
215
|
+
let cssBundling = null
|
|
216
|
+
if (hasGem('tailwindcss-rails')) cssBundling = 'tailwind'
|
|
217
|
+
else if (hasGem('cssbundling-rails')) cssBundling = 'cssbundling'
|
|
218
|
+
else if (hasGem('bootstrap')) cssBundling = 'bootstrap'
|
|
219
|
+
else if (hasGem('sassc-rails') || hasGem('sass-rails')) cssBundling = 'sass'
|
|
220
|
+
|
|
221
|
+
// Auth
|
|
222
|
+
let auth = null
|
|
223
|
+
if (hasGem('devise')) auth = 'devise'
|
|
224
|
+
else if (
|
|
225
|
+
provider.fileExists('app/models/session.rb') ||
|
|
226
|
+
provider.fileExists('app/models/current.rb')
|
|
227
|
+
) {
|
|
228
|
+
auth = 'native'
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Job adapter
|
|
232
|
+
let jobAdapter = null
|
|
233
|
+
if (hasGem('solid_queue')) jobAdapter = 'solid_queue'
|
|
234
|
+
else if (hasGem('sidekiq')) jobAdapter = 'sidekiq'
|
|
235
|
+
else if (hasGem('good_job')) jobAdapter = 'good_job'
|
|
236
|
+
else if (hasGem('delayed_job')) jobAdapter = 'delayed_job'
|
|
237
|
+
else jobAdapter = 'async'
|
|
238
|
+
|
|
239
|
+
// Cache store
|
|
240
|
+
let cacheStore = null
|
|
241
|
+
if (hasGem('solid_cache')) cacheStore = 'solid_cache'
|
|
242
|
+
else if (hasGem('redis')) cacheStore = 'redis'
|
|
243
|
+
// Also check config
|
|
244
|
+
const prodConfig =
|
|
245
|
+
provider.readFile('config/environments/production.rb') || ''
|
|
246
|
+
const cacheStoreMatch = prodConfig.match(/config\.cache_store\s*=\s*:(\w+)/)
|
|
247
|
+
if (cacheStoreMatch) cacheStore = cacheStoreMatch[1]
|
|
248
|
+
|
|
249
|
+
// Cable adapter
|
|
250
|
+
let cableAdapter = null
|
|
251
|
+
if (hasGem('solid_cable')) cableAdapter = 'solid_cable'
|
|
252
|
+
else {
|
|
253
|
+
const cableYml = provider.readFile('config/cable.yml') || ''
|
|
254
|
+
const adapterMatch = cableYml.match(/production:\s*\n\s*adapter:\s*(\w+)/)
|
|
255
|
+
if (adapterMatch) cableAdapter = adapterMatch[1]
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Test framework
|
|
259
|
+
let testFramework = null
|
|
260
|
+
if (hasGem('rspec-rails') || hasGem('rspec')) testFramework = 'rspec'
|
|
261
|
+
else if (hasGem('minitest') || provider.fileExists('test'))
|
|
262
|
+
testFramework = 'minitest'
|
|
263
|
+
|
|
264
|
+
// Deployment
|
|
265
|
+
let deploy = null
|
|
266
|
+
if (
|
|
267
|
+
hasGem('kamal') ||
|
|
268
|
+
provider.fileExists('config/deploy.yml') ||
|
|
269
|
+
provider.fileExists('.kamal')
|
|
270
|
+
)
|
|
271
|
+
deploy = 'kamal'
|
|
272
|
+
else if (hasGem('capistrano')) deploy = 'capistrano'
|
|
273
|
+
else if (provider.fileExists('Procfile')) deploy = 'heroku'
|
|
274
|
+
else if (provider.fileExists('Dockerfile')) deploy = 'docker'
|
|
275
|
+
|
|
276
|
+
// Hotwire
|
|
277
|
+
const hotwire = hasGem('turbo-rails') || hasGem('hotwire-rails')
|
|
278
|
+
|
|
279
|
+
// API only
|
|
280
|
+
const apiOnly = CONFIG_PATTERNS.apiOnly.test(appConfig)
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
assetPipeline,
|
|
284
|
+
jsBundling,
|
|
285
|
+
cssBundling,
|
|
286
|
+
auth,
|
|
287
|
+
jobAdapter,
|
|
288
|
+
cacheStore,
|
|
289
|
+
cableAdapter,
|
|
290
|
+
testFramework,
|
|
291
|
+
deploy,
|
|
292
|
+
hotwire,
|
|
293
|
+
apiOnly,
|
|
294
|
+
}
|
|
295
|
+
}
|