@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,510 @@
1
+ /**
2
+ * Graph Builder + Personalized PageRank
3
+ * Builds a relationship graph from extractions and computes
4
+ * skill-personalized PageRank rankings.
5
+ */
6
+
7
+ import { RANK_PRECISION } from './constants.js'
8
+ import { classify as inflectorClassify } from '../utils/inflector.js'
9
+
10
+ /** Edge type weights */
11
+ export const EDGE_WEIGHTS = {
12
+ inherits: 3.0,
13
+ includes_concern: 2.5,
14
+ has_many: 2.0,
15
+ belongs_to: 2.0,
16
+ has_one: 2.0,
17
+ has_many_through: 1.8,
18
+ polymorphic: 1.5,
19
+ schema_fk: 2.0,
20
+ routes_to: 1.5,
21
+ convention_pair: 1.5,
22
+ renders_component: 1.5,
23
+ attaches_stimulus: 1.0,
24
+ manages_attachment: 1.0,
25
+ sends_mail: 1.0,
26
+ enqueues_job: 1.0,
27
+ broadcasts_to: 1.5,
28
+ authorizes_via: 1.5,
29
+ serializes: 1.5,
30
+ validates_with: 0.5,
31
+ delegates_to: 1.0,
32
+ contains: 0.5,
33
+ references: 1.0,
34
+ tests: 1.0,
35
+ helps_view: 0.5,
36
+ manages_upload: 1.0,
37
+ }
38
+
39
+ export class Graph {
40
+ constructor() {
41
+ /** @type {Map<string, {id: string, type: string, label: string}>} */
42
+ this.nodes = new Map()
43
+ /** @type {Array<{from: string, to: string, type: string, weight: number}>} */
44
+ this.edges = []
45
+ /** @type {Map<string, Array<{to: string, weight: number, type: string}>>} */
46
+ this.adjacency = new Map()
47
+ /** @type {Map<string, Array<{from: string, weight: number, type: string}>>} */
48
+ this.reverseAdjacency = new Map()
49
+ }
50
+
51
+ /**
52
+ * Add a node to the graph.
53
+ * @param {string} id
54
+ * @param {string} type - e.g. 'model', 'controller', 'view'
55
+ * @param {string} [label]
56
+ */
57
+ addNode(id, type, label) {
58
+ if (!this.nodes.has(id)) {
59
+ this.nodes.set(id, { id, type, label: label || id })
60
+ this.adjacency.set(id, [])
61
+ this.reverseAdjacency.set(id, [])
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Add a directed edge between nodes.
67
+ * @param {string} from
68
+ * @param {string} to
69
+ * @param {string} type - One of EDGE_WEIGHTS keys
70
+ */
71
+ addEdge(from, to, type) {
72
+ const weight = EDGE_WEIGHTS[type] || 1.0
73
+ // Ensure nodes exist
74
+ if (!this.nodes.has(from)) this.addNode(from, 'unknown')
75
+ if (!this.nodes.has(to)) this.addNode(to, 'unknown')
76
+
77
+ this.edges.push({ from, to, type, weight })
78
+ this.adjacency.get(from).push({ to, weight, type })
79
+ if (!this.reverseAdjacency.has(to)) this.reverseAdjacency.set(to, [])
80
+ this.reverseAdjacency.get(to).push({ from, weight, type })
81
+ }
82
+
83
+ /**
84
+ * BFS traversal from seed nodes through forward and reverse adjacency.
85
+ * @param {string[]} seedIds - Starting entity IDs
86
+ * @param {number} [maxDepth=3] - Maximum BFS hops
87
+ * @param {Object} [options]
88
+ * @param {Set<string>} [options.excludeEdgeTypes] - Edge types to skip
89
+ * @param {number} [options.minEdgeWeight] - Minimum edge weight to traverse (default 0)
90
+ * @returns {Array<{entity: string, distance: number, reachedVia: string, edgeType: string, direction: string}>}
91
+ */
92
+ bfsFromSeeds(seedIds, maxDepth = 3, options = {}) {
93
+ const { excludeEdgeTypes = new Set(), minEdgeWeight = 0 } = options
94
+ const visited = new Set()
95
+ const results = []
96
+ const queue = []
97
+
98
+ for (const id of seedIds) {
99
+ if (this.nodes.has(id)) {
100
+ visited.add(id)
101
+ queue.push({
102
+ entity: id,
103
+ distance: 0,
104
+ reachedVia: null,
105
+ edgeType: null,
106
+ direction: null,
107
+ })
108
+ }
109
+ }
110
+
111
+ while (queue.length > 0) {
112
+ const current = queue.shift()
113
+ if (current.distance > 0) {
114
+ results.push(current)
115
+ }
116
+ if (current.distance >= maxDepth) continue
117
+
118
+ this._enqueueNeighbours(
119
+ current,
120
+ 'forward',
121
+ visited,
122
+ queue,
123
+ excludeEdgeTypes,
124
+ minEdgeWeight,
125
+ )
126
+ this._enqueueNeighbours(
127
+ current,
128
+ 'reverse',
129
+ visited,
130
+ queue,
131
+ excludeEdgeTypes,
132
+ minEdgeWeight,
133
+ )
134
+ }
135
+
136
+ return results
137
+ }
138
+
139
+ /** @private */
140
+ _enqueueNeighbours(
141
+ current,
142
+ direction,
143
+ visited,
144
+ queue,
145
+ excludeEdgeTypes,
146
+ minEdgeWeight,
147
+ ) {
148
+ const neighbours =
149
+ direction === 'forward'
150
+ ? this.adjacency.get(current.entity) || []
151
+ : this.reverseAdjacency.get(current.entity) || []
152
+
153
+ for (const edge of neighbours) {
154
+ const neighbour = direction === 'forward' ? edge.to : edge.from
155
+ const edgeType = edge.type
156
+ if (visited.has(neighbour)) continue
157
+ if (excludeEdgeTypes.has(edgeType)) continue
158
+ if (edge.weight < minEdgeWeight) continue
159
+
160
+ visited.add(neighbour)
161
+ queue.push({
162
+ entity: neighbour,
163
+ distance: current.distance + 1,
164
+ reachedVia: current.entity,
165
+ edgeType,
166
+ direction,
167
+ })
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Personalized PageRank via power iteration.
173
+ *
174
+ * Computes the importance of each node in the graph using an iterative
175
+ * algorithm. The personalization map biases the "random surfer" toward
176
+ * specific nodes (e.g. skill-relevant entities), so results are scoped
177
+ * to a domain rather than purely structural.
178
+ *
179
+ * Algorithm:
180
+ * rank(v) = (1 - d) * p(v) + d * Σ [rank(u) * w(u→v) / Σw(u→*)]
181
+ * where d = damping factor, p(v) = personalization weight (normalized).
182
+ *
183
+ * @param {Object} [personalization] - Map of node id → bias weight (default: uniform)
184
+ * @param {number} [damping=0.85] - Probability of following a link (vs teleporting)
185
+ * @param {number} [maxIter=50] - Maximum power-iteration rounds
186
+ * @param {number} [tolerance=1e-6] - L1-norm convergence threshold
187
+ * @returns {Map<string, number>} Node id → rank score
188
+ */
189
+ personalizedPageRank(
190
+ personalization = {},
191
+ damping = 0.85,
192
+ maxIter = 50,
193
+ tolerance = 1e-6,
194
+ ) {
195
+ const n = this.nodes.size
196
+ if (n === 0) return new Map()
197
+
198
+ const nodeIds = [...this.nodes.keys()]
199
+ const idxMap = new Map(nodeIds.map((id, i) => [id, i]))
200
+
201
+ // Build personalization vector
202
+ const pVec = new Float64Array(n)
203
+ let pSum = 0
204
+ for (const id of nodeIds) {
205
+ const val = personalization[id] || 1.0
206
+ pVec[idxMap.get(id)] = val
207
+ pSum += val
208
+ }
209
+ // Normalize
210
+ for (let i = 0; i < n; i++) pVec[i] /= pSum
211
+
212
+ // Initialize ranks uniformly
213
+ let ranks = new Float64Array(n).fill(1 / n)
214
+
215
+ for (let iter = 0; iter < maxIter; iter++) {
216
+ const newRanks = new Float64Array(n)
217
+
218
+ // Teleport component: with probability (1-d), jump to a random node
219
+ // weighted by the personalization vector instead of following links
220
+ for (let i = 0; i < n; i++) {
221
+ newRanks[i] = (1 - damping) * pVec[i]
222
+ }
223
+
224
+ // Link component: propagate rank along weighted edges
225
+ for (const id of nodeIds) {
226
+ const idx = idxMap.get(id)
227
+ const outEdges = this.adjacency.get(id)
228
+ if (outEdges.length === 0) {
229
+ // Dangling node (no outgoing edges): redistribute its rank to all
230
+ // nodes proportionally to the personalization vector, preventing
231
+ // rank from being "trapped" in dead-end nodes
232
+ for (let i = 0; i < n; i++) {
233
+ newRanks[i] += damping * ranks[idx] * pVec[i]
234
+ }
235
+ } else {
236
+ // Distribute rank to neighbours weighted by edge strength
237
+ const totalWeight = outEdges.reduce((s, e) => s + e.weight, 0)
238
+ for (const edge of outEdges) {
239
+ const toIdx = idxMap.get(edge.to)
240
+ newRanks[toIdx] +=
241
+ damping * ranks[idx] * (edge.weight / totalWeight)
242
+ }
243
+ }
244
+ }
245
+
246
+ // Convergence check: stop early if L1-norm change is below tolerance
247
+ let diff = 0
248
+ for (let i = 0; i < n; i++) diff += Math.abs(newRanks[i] - ranks[i])
249
+ ranks = newRanks
250
+ if (diff < tolerance) break
251
+ }
252
+
253
+ // Convert to Map
254
+ const result = new Map()
255
+ for (const id of nodeIds) {
256
+ result.set(id, ranks[idxMap.get(id)])
257
+ }
258
+ return result
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Extract class_name override from association options string.
264
+ * @param {string|null} options - Raw options string from extractor
265
+ * @returns {string|null} Class name or null
266
+ */
267
+ function extractClassName(options) {
268
+ if (!options) return null
269
+ const match = options.match(/class_name:\s*['"](\w+(?:::\w+)*)['"]/)
270
+ return match ? match[1] : null
271
+ }
272
+
273
+ /**
274
+ * Build a graph from index extractions.
275
+ * @param {object} extractions - All extraction results
276
+ * @param {object} manifest - Scanner manifest with entries
277
+ * @param {string[]} [skills] - Skill-relevant file patterns for personalization
278
+ * @returns {{graph: Graph, relationships: Array, rankings: object}}
279
+ */
280
+ export function buildGraph(extractions, manifest, skills = []) {
281
+ const graph = new Graph()
282
+ const relationships = []
283
+
284
+ // Add model nodes and relationships
285
+ if (extractions.models) {
286
+ for (const [name, model] of Object.entries(extractions.models)) {
287
+ graph.addNode(name, 'model', name)
288
+
289
+ // Inheritance
290
+ if (model.superclass && model.superclass !== 'ApplicationRecord') {
291
+ graph.addEdge(name, model.superclass, 'inherits')
292
+ relationships.push({
293
+ from: name,
294
+ to: model.superclass,
295
+ type: 'inherits',
296
+ })
297
+ }
298
+
299
+ // Associations
300
+ if (model.associations) {
301
+ for (const assoc of model.associations) {
302
+ const type = assoc.type.replace(':', '')
303
+
304
+ // Skip phantom edges for polymorphic belongs_to
305
+ if (type === 'belongs_to' && assoc.polymorphic) continue
306
+
307
+ const classNameOverride = extractClassName(assoc.options)
308
+ const target = classNameOverride || classify(assoc.name)
309
+ graph.addNode(target, 'model', target)
310
+ if (EDGE_WEIGHTS[type]) {
311
+ graph.addEdge(name, target, type)
312
+ relationships.push({ from: name, to: target, type })
313
+ }
314
+
315
+ // Add join model edge for through associations
316
+ if (assoc.through) {
317
+ const joinModel = classify(assoc.through)
318
+ graph.addNode(joinModel, 'model', joinModel)
319
+ graph.addEdge(name, joinModel, 'has_many')
320
+ relationships.push({ from: name, to: joinModel, type: 'has_many' })
321
+ }
322
+ // Note: polymorphic has_many with `as:` option creates a valid edge
323
+ // to the target model (e.g. has_many :comments, as: :commentable)
324
+ }
325
+ }
326
+
327
+ // Concerns
328
+ if (model.concerns) {
329
+ for (const concern of model.concerns) {
330
+ graph.addNode(concern, 'concern', concern)
331
+ graph.addEdge(name, concern, 'includes_concern')
332
+ relationships.push({
333
+ from: name,
334
+ to: concern,
335
+ type: 'includes_concern',
336
+ })
337
+ }
338
+ }
339
+
340
+ // Delegations
341
+ if (model.delegations) {
342
+ for (const del of model.delegations) {
343
+ if (del.to) {
344
+ const target = classify(del.to)
345
+ graph.addEdge(name, target, 'delegates_to')
346
+ relationships.push({ from: name, to: target, type: 'delegates_to' })
347
+ }
348
+ }
349
+ }
350
+ }
351
+ }
352
+
353
+ // Controller → Model convention pairs
354
+ if (extractions.controllers) {
355
+ for (const [name, ctrl] of Object.entries(extractions.controllers)) {
356
+ graph.addNode(name, 'controller', name)
357
+ // Convention: PostsController → Post model
358
+ const modelName = name.replace(/Controller$/, '').replace(/s$/, '')
359
+ if (extractions.models && extractions.models[modelName]) {
360
+ graph.addEdge(name, modelName, 'convention_pair')
361
+ relationships.push({
362
+ from: name,
363
+ to: modelName,
364
+ type: 'convention_pair',
365
+ })
366
+ }
367
+ }
368
+ }
369
+
370
+ // Routes → Controller
371
+ if (extractions.routes && extractions.routes.routes) {
372
+ for (const route of extractions.routes.routes) {
373
+ if (route.controller) {
374
+ const ctrlName = classify(route.controller) + 'Controller'
375
+ graph.addEdge('routes', ctrlName, 'routes_to')
376
+ relationships.push({ from: 'routes', to: ctrlName, type: 'routes_to' })
377
+ }
378
+ }
379
+ }
380
+
381
+ // Schema foreign keys
382
+ if (extractions.schema && extractions.schema.foreign_keys) {
383
+ for (const fk of extractions.schema.foreign_keys) {
384
+ graph.addEdge(fk.from_table, fk.to_table, 'schema_fk')
385
+ relationships.push({
386
+ from: fk.from_table,
387
+ to: fk.to_table,
388
+ type: 'schema_fk',
389
+ })
390
+ }
391
+ }
392
+
393
+ // Spec → Source relationships (test files → tested entities)
394
+ if (extractions.test_conventions) {
395
+ const specEntries =
396
+ manifest.entries?.filter(
397
+ (e) =>
398
+ e.category === 19 && e.specCategory && e.path.endsWith('_spec.rb'),
399
+ ) || []
400
+
401
+ for (const entry of specEntries) {
402
+ // Derive the model/controller name from the spec path
403
+ const basename = entry.path.split('/').pop().replace('_spec.rb', '')
404
+ const className = classify(basename)
405
+
406
+ if (entry.specCategory === 'model_specs') {
407
+ if (extractions.models && extractions.models[className]) {
408
+ graph.addNode(`spec:${className}`, 'spec', `${className} spec`)
409
+ graph.addEdge(`spec:${className}`, className, 'tests')
410
+ relationships.push({
411
+ from: `spec:${className}`,
412
+ to: className,
413
+ type: 'tests',
414
+ })
415
+ }
416
+ } else if (
417
+ entry.specCategory === 'request_specs' ||
418
+ entry.specCategory === 'controller_specs'
419
+ ) {
420
+ // Controller names are plural (UsersController), so don't singularize
421
+ const ctrlBaseName = basename
422
+ .split('_')
423
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
424
+ .join('')
425
+ const ctrlName = ctrlBaseName + 'Controller'
426
+ if (extractions.controllers && extractions.controllers[ctrlName]) {
427
+ graph.addNode(`spec:${ctrlName}`, 'spec', `${ctrlName} spec`)
428
+ graph.addEdge(`spec:${ctrlName}`, ctrlName, 'tests')
429
+ relationships.push({
430
+ from: `spec:${ctrlName}`,
431
+ to: ctrlName,
432
+ type: 'tests',
433
+ })
434
+ }
435
+ }
436
+ }
437
+ }
438
+
439
+ // Build personalization from skills
440
+ const personalization = {}
441
+ if (skills.length > 0 && manifest && manifest.entries) {
442
+ for (const entry of manifest.entries) {
443
+ for (const skill of skills) {
444
+ if (entry.path.includes(skill)) {
445
+ personalization[entry.path] = 3.0
446
+ }
447
+ }
448
+ }
449
+ }
450
+
451
+ // Helpers → Controllers (by naming convention)
452
+ if (extractions.helpers) {
453
+ for (const [name, helper] of Object.entries(extractions.helpers)) {
454
+ graph.addNode(name, 'helper', name)
455
+ if (
456
+ helper.controller &&
457
+ extractions.controllers &&
458
+ extractions.controllers[helper.controller]
459
+ ) {
460
+ graph.addEdge(name, helper.controller, 'helps_view')
461
+ relationships.push({
462
+ from: name,
463
+ to: helper.controller,
464
+ type: 'helps_view',
465
+ })
466
+ }
467
+ }
468
+ }
469
+
470
+ // Workers — add as nodes (same category as jobs)
471
+ if (extractions.workers) {
472
+ for (const [name, worker] of Object.entries(extractions.workers)) {
473
+ graph.addNode(name, 'worker', name)
474
+ }
475
+ }
476
+
477
+ // Uploaders → Models (via mount_uploader cross-reference)
478
+ if (extractions.uploaders?.mounted) {
479
+ for (const mount of extractions.uploaders.mounted) {
480
+ const uploaderClass = mount.uploader
481
+ if (extractions.uploaders.uploaders?.[uploaderClass]) {
482
+ graph.addNode(uploaderClass, 'uploader', uploaderClass)
483
+ graph.addEdge(mount.model, uploaderClass, 'manages_upload')
484
+ relationships.push({
485
+ from: mount.model,
486
+ to: uploaderClass,
487
+ type: 'manages_upload',
488
+ })
489
+ }
490
+ }
491
+ }
492
+
493
+ // Compute PageRank
494
+ const rankMap = graph.personalizedPageRank(personalization)
495
+ const rankings = {}
496
+ for (const [id, score] of rankMap) {
497
+ rankings[id] = Math.round(score * RANK_PRECISION) / RANK_PRECISION
498
+ }
499
+
500
+ return { graph, relationships, rankings }
501
+ }
502
+
503
+ /**
504
+ * Convert a snake_case or plural string to a PascalCase singular class name.
505
+ * @param {string} str
506
+ * @returns {string}
507
+ */
508
+ export function classify(str) {
509
+ return inflectorClassify(str)
510
+ }