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