@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,496 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Blast Radius Engine
|
|
3
|
+
* Computes impact analysis for code changes using BFS traversal
|
|
4
|
+
* through RailsInsight's relationship graph.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @typedef {Object} BlastRadiusSeed
|
|
9
|
+
* @property {string} file - Changed file path
|
|
10
|
+
* @property {string} entity - Mapped graph entity name
|
|
11
|
+
* @property {string} type - Entity type (model, controller, etc.)
|
|
12
|
+
* @property {string} status - Git change status (added, modified, deleted)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {Object} BlastRadiusImpact
|
|
17
|
+
* @property {string} entity - Impacted entity name
|
|
18
|
+
* @property {string} type - Entity type
|
|
19
|
+
* @property {'CRITICAL'|'HIGH'|'MEDIUM'|'LOW'} risk - Risk classification
|
|
20
|
+
* @property {number} distance - BFS hops from nearest seed
|
|
21
|
+
* @property {string} reachedVia - Entity that led to this one
|
|
22
|
+
* @property {string} edgeType - Graph edge type traversed
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} BlastRadiusResult
|
|
27
|
+
* @property {BlastRadiusSeed[]} seeds - Changed file → entity mappings
|
|
28
|
+
* @property {BlastRadiusImpact[]} impacted - Entities affected by the change
|
|
29
|
+
* @property {BlastRadiusImpact[]} impactedTests - Test files affected
|
|
30
|
+
* @property {Object} summary - Counts per risk level
|
|
31
|
+
* @property {string[]} warnings - Unmapped files or other notes
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { EDGE_WEIGHTS } from './graph.js'
|
|
35
|
+
import {
|
|
36
|
+
estimateTokens,
|
|
37
|
+
estimateTokensForObject,
|
|
38
|
+
} from '../utils/token-counter.js'
|
|
39
|
+
import { DEFAULT_TOKEN_BUDGET } from './constants.js'
|
|
40
|
+
|
|
41
|
+
const RISK_LEVELS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW']
|
|
42
|
+
const STRONG_EDGE_THRESHOLD = 2.0
|
|
43
|
+
const AUTH_ENTITY_PATTERNS = /devise|authenticat|authorization|pundit|cancan/i
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compute the blast radius for a set of changed files.
|
|
47
|
+
* @param {Object} index - Full RailsInsight index
|
|
48
|
+
* @param {Array<{path: string, status: string}>} changedFiles
|
|
49
|
+
* @param {Object} [options]
|
|
50
|
+
* @param {number} [options.maxDepth] - BFS depth limit (default: 3)
|
|
51
|
+
* @param {number} [options.tokenBudget] - Token budget for response (default: 8000)
|
|
52
|
+
* @returns {BlastRadiusResult}
|
|
53
|
+
*/
|
|
54
|
+
export function computeBlastRadius(index, changedFiles, options = {}) {
|
|
55
|
+
const { maxDepth = 3 } = options
|
|
56
|
+
|
|
57
|
+
if (!changedFiles || changedFiles.length === 0) {
|
|
58
|
+
return emptyResult('No changes detected')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const fileEntityMap = index.fileEntityMap || {}
|
|
62
|
+
const seeds = mapFilesToSeeds(changedFiles, fileEntityMap)
|
|
63
|
+
const warnings = collectUnmappedWarnings(changedFiles, fileEntityMap)
|
|
64
|
+
|
|
65
|
+
if (seeds.length === 0) {
|
|
66
|
+
return {
|
|
67
|
+
seeds: [],
|
|
68
|
+
impacted: [],
|
|
69
|
+
impactedTests: [],
|
|
70
|
+
summary: buildSummary([]),
|
|
71
|
+
warnings,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const graph = index.graph
|
|
76
|
+
if (!graph) {
|
|
77
|
+
return emptyResult('No graph available — re-index required')
|
|
78
|
+
}
|
|
79
|
+
const seedIds = extractSeedIds(seeds, index)
|
|
80
|
+
const bfsResults = graph.bfsFromSeeds(seedIds, maxDepth, {
|
|
81
|
+
excludeEdgeTypes: new Set(['contains', 'tests']),
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const impacted = buildImpactedEntities(bfsResults, seeds, index)
|
|
85
|
+
const escalated = escalateRailsSpecificRisks(impacted, seeds, index)
|
|
86
|
+
const deduplicated = deduplicateByHighestRisk(escalated)
|
|
87
|
+
const sorted = sortByRisk(deduplicated)
|
|
88
|
+
const impactedTests = collectImpactedTests(sorted, seeds, graph, index)
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
seeds,
|
|
92
|
+
impacted: sorted,
|
|
93
|
+
impactedTests,
|
|
94
|
+
summary: buildSummary(sorted),
|
|
95
|
+
warnings,
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Classify risk level for an impacted entity based on graph distance,
|
|
101
|
+
* edge strength, and Rails-specific security heuristics.
|
|
102
|
+
*
|
|
103
|
+
* Risk escalation rules (evaluated top-to-bottom, first match wins):
|
|
104
|
+
* - Distance 0 (the changed entity itself) → always CRITICAL
|
|
105
|
+
* - Auth-related entities at distance ≤1 → HIGH (security-sensitive)
|
|
106
|
+
* - Schema changes propagating to distance ≤1 → CRITICAL (column/table changes break dependents)
|
|
107
|
+
* - Distance 1 via strong edge (weight ≥ 2.0, e.g. has_many, belongs_to, inherits) → HIGH
|
|
108
|
+
* - Distance 1 via weak edge → MEDIUM
|
|
109
|
+
* - Distance 2 via strong edge → MEDIUM
|
|
110
|
+
* - Everything else ≤2 → LOW
|
|
111
|
+
* - Distance 3+ → LOW
|
|
112
|
+
*
|
|
113
|
+
* @param {Object} entity - BFS result entity with { distance, edgeType }
|
|
114
|
+
* @param {Object} seedInfo - Information about the seed (changed) entity
|
|
115
|
+
* @param {Object} index - Full RailsInsight index for entity lookups
|
|
116
|
+
* @returns {'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'}
|
|
117
|
+
*/
|
|
118
|
+
export function classifyRisk(entity, seedInfo, index) {
|
|
119
|
+
// The changed entity itself is always critical
|
|
120
|
+
if (entity.distance === 0) return 'CRITICAL'
|
|
121
|
+
|
|
122
|
+
// Auth/authorization entities are security-sensitive — escalate direct neighbours
|
|
123
|
+
if (isAuthRelated(entity, index) && entity.distance <= 1) return 'HIGH'
|
|
124
|
+
// Schema changes (columns, tables) break all direct dependents
|
|
125
|
+
if (isSchemaChange(seedInfo) && entity.distance <= 1) return 'CRITICAL'
|
|
126
|
+
|
|
127
|
+
const edgeWeight = EDGE_WEIGHTS[entity.edgeType] || 1.0
|
|
128
|
+
// Strong edges (≥2.0) are structural relationships like associations and inheritance
|
|
129
|
+
const isStrongEdge = edgeWeight >= STRONG_EDGE_THRESHOLD
|
|
130
|
+
|
|
131
|
+
if (entity.distance === 1 && isStrongEdge) return 'HIGH'
|
|
132
|
+
if (entity.distance === 1) return 'MEDIUM'
|
|
133
|
+
if (entity.distance === 2 && isStrongEdge) return 'MEDIUM'
|
|
134
|
+
if (entity.distance <= 2) return 'LOW'
|
|
135
|
+
return 'LOW'
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Build a review context summary within a token budget.
|
|
140
|
+
* @param {Object} index - Full RailsInsight index
|
|
141
|
+
* @param {BlastRadiusResult} blastResult - Output of computeBlastRadius
|
|
142
|
+
* @param {number} [tokenBudget=8000]
|
|
143
|
+
* @returns {Object}
|
|
144
|
+
*/
|
|
145
|
+
export function buildReviewContext(
|
|
146
|
+
index,
|
|
147
|
+
blastResult,
|
|
148
|
+
tokenBudget = DEFAULT_TOKEN_BUDGET,
|
|
149
|
+
) {
|
|
150
|
+
const context = {
|
|
151
|
+
seeds: blastResult.seeds,
|
|
152
|
+
summary: blastResult.summary,
|
|
153
|
+
warnings: blastResult.warnings,
|
|
154
|
+
entities: [],
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const headerTokens = estimateTokensForObject({
|
|
158
|
+
seeds: context.seeds,
|
|
159
|
+
summary: context.summary,
|
|
160
|
+
warnings: context.warnings,
|
|
161
|
+
})
|
|
162
|
+
let remainingBudget = tokenBudget - headerTokens
|
|
163
|
+
|
|
164
|
+
const grouped = groupByRisk(blastResult.impacted)
|
|
165
|
+
|
|
166
|
+
for (const risk of RISK_LEVELS) {
|
|
167
|
+
const entities = grouped[risk] || []
|
|
168
|
+
for (const entity of entities) {
|
|
169
|
+
const summary = buildEntitySummary(entity, index)
|
|
170
|
+
const tokens = estimateTokensForObject(summary)
|
|
171
|
+
|
|
172
|
+
if (tokens <= remainingBudget) {
|
|
173
|
+
context.entities.push(summary)
|
|
174
|
+
remainingBudget -= tokens
|
|
175
|
+
} else {
|
|
176
|
+
const compact = compactEntitySummary(entity)
|
|
177
|
+
const compactTokens = estimateTokensForObject(compact)
|
|
178
|
+
if (compactTokens <= remainingBudget) {
|
|
179
|
+
context.entities.push(compact)
|
|
180
|
+
remainingBudget -= compactTokens
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return context
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// --- Private helpers ---
|
|
190
|
+
|
|
191
|
+
function emptyResult(message) {
|
|
192
|
+
return {
|
|
193
|
+
seeds: [],
|
|
194
|
+
impacted: [],
|
|
195
|
+
impactedTests: [],
|
|
196
|
+
summary: { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, total: 0 },
|
|
197
|
+
warnings: [],
|
|
198
|
+
message,
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function mapFilesToSeeds(changedFiles, fileEntityMap) {
|
|
203
|
+
const seeds = []
|
|
204
|
+
for (const file of changedFiles) {
|
|
205
|
+
const mapping = fileEntityMap[file.path]
|
|
206
|
+
if (mapping) {
|
|
207
|
+
seeds.push({
|
|
208
|
+
path: file.path,
|
|
209
|
+
entity: mapping.entity,
|
|
210
|
+
type: mapping.type,
|
|
211
|
+
status: file.status,
|
|
212
|
+
})
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
return seeds
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function collectUnmappedWarnings(changedFiles, fileEntityMap) {
|
|
219
|
+
const warnings = []
|
|
220
|
+
for (const file of changedFiles) {
|
|
221
|
+
if (!fileEntityMap[file.path]) {
|
|
222
|
+
warnings.push(`Unmapped file: ${file.path}`)
|
|
223
|
+
} else if (fileEntityMap[file.path]?.entity === '__gemfile__') {
|
|
224
|
+
warnings.push('Gemfile change — dependency blast radius unknown')
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return warnings
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function extractSeedIds(seeds, index) {
|
|
231
|
+
const ids = []
|
|
232
|
+
for (const seed of seeds) {
|
|
233
|
+
if (seed.entity === '__schema__') {
|
|
234
|
+
addSchemaSeeds(ids, index)
|
|
235
|
+
} else if (seed.entity === '__routes__') {
|
|
236
|
+
addRouteSeeds(ids, index)
|
|
237
|
+
} else if (seed.entity === '__gemfile__') {
|
|
238
|
+
continue
|
|
239
|
+
} else {
|
|
240
|
+
ids.push(seed.entity)
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return [...new Set(ids)]
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function addSchemaSeeds(ids, index) {
|
|
247
|
+
const models = index.extractions?.models || {}
|
|
248
|
+
for (const name of Object.keys(models)) {
|
|
249
|
+
ids.push(name)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function addRouteSeeds(ids, index) {
|
|
254
|
+
const controllers = index.extractions?.controllers || {}
|
|
255
|
+
for (const name of Object.keys(controllers)) {
|
|
256
|
+
ids.push(name)
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function buildImpactedEntities(bfsResults, seeds, index) {
|
|
261
|
+
const fileEntityMap = index.fileEntityMap || {}
|
|
262
|
+
const reverseMap = buildReverseEntityFileMap(fileEntityMap)
|
|
263
|
+
|
|
264
|
+
return bfsResults.map((result) => {
|
|
265
|
+
const nodeInfo = findEntityInfo(result.entity, index)
|
|
266
|
+
const seedInfo = findSeedForEntity(result.reachedVia, seeds, index)
|
|
267
|
+
const risk = classifyRisk(result, seedInfo, index)
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
entity: result.entity,
|
|
271
|
+
type: nodeInfo?.type || 'unknown',
|
|
272
|
+
risk,
|
|
273
|
+
distance: result.distance,
|
|
274
|
+
reachedVia: result.reachedVia,
|
|
275
|
+
edgeType: result.edgeType,
|
|
276
|
+
file: reverseMap[result.entity] || null,
|
|
277
|
+
reason: buildReason(result, risk),
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function buildReverseEntityFileMap(fileEntityMap) {
|
|
283
|
+
const reverse = {}
|
|
284
|
+
for (const [path, mapping] of Object.entries(fileEntityMap)) {
|
|
285
|
+
reverse[mapping.entity] = path
|
|
286
|
+
}
|
|
287
|
+
return reverse
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function findEntityInfo(entityId, index) {
|
|
291
|
+
const models = index.extractions?.models || {}
|
|
292
|
+
if (models[entityId]) return { type: 'model', data: models[entityId] }
|
|
293
|
+
|
|
294
|
+
const controllers = index.extractions?.controllers || {}
|
|
295
|
+
if (controllers[entityId])
|
|
296
|
+
return { type: 'controller', data: controllers[entityId] }
|
|
297
|
+
|
|
298
|
+
const components = index.extractions?.components || {}
|
|
299
|
+
if (components[entityId])
|
|
300
|
+
return { type: 'component', data: components[entityId] }
|
|
301
|
+
|
|
302
|
+
if (entityId.startsWith('spec:')) return { type: 'spec', data: null }
|
|
303
|
+
return null
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function findSeedForEntity(reachedVia, seeds, index) {
|
|
307
|
+
const seed = seeds.find((s) => s.entity === reachedVia)
|
|
308
|
+
if (seed) return seed
|
|
309
|
+
return seeds[0] || {}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function isAuthRelated(entity, index) {
|
|
313
|
+
if (AUTH_ENTITY_PATTERNS.test(entity.entity)) return true
|
|
314
|
+
const models = index.extractions?.models || {}
|
|
315
|
+
const model = models[entity.entity]
|
|
316
|
+
if (model?.concerns?.some((c) => AUTH_ENTITY_PATTERNS.test(c))) return true
|
|
317
|
+
return false
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function isSchemaChange(seedInfo) {
|
|
321
|
+
return seedInfo?.type === 'schema' || seedInfo?.entity === '__schema__'
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function buildReason(result, risk) {
|
|
325
|
+
if (result.distance === 0) return 'Direct change'
|
|
326
|
+
return `Reachable from ${result.reachedVia} via ${result.edgeType} (distance ${result.distance})`
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function escalateRailsSpecificRisks(impacted, seeds, index) {
|
|
330
|
+
const hasConcernSeed = seeds.some((s) => s.type === 'concern')
|
|
331
|
+
const hasSchemaChange = seeds.some((s) => s.type === 'schema')
|
|
332
|
+
const hasAuthChange = seeds.some((s) => isAuthRelatedSeed(s, index))
|
|
333
|
+
|
|
334
|
+
return impacted.map((entity) => {
|
|
335
|
+
let risk = entity.risk
|
|
336
|
+
|
|
337
|
+
if (
|
|
338
|
+
hasConcernSeed &&
|
|
339
|
+
entity.distance <= 1 &&
|
|
340
|
+
RISK_LEVELS.indexOf(risk) > RISK_LEVELS.indexOf('HIGH')
|
|
341
|
+
) {
|
|
342
|
+
risk = 'HIGH'
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
if (hasSchemaChange && entity.type === 'model' && entity.distance <= 1) {
|
|
346
|
+
risk = 'CRITICAL'
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (hasAuthChange && isAuthRelated(entity, index) && entity.distance <= 1) {
|
|
350
|
+
risk = 'HIGH'
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
return { ...entity, risk }
|
|
354
|
+
})
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isAuthRelatedSeed(seed, index) {
|
|
358
|
+
if (AUTH_ENTITY_PATTERNS.test(seed.entity)) return true
|
|
359
|
+
const models = index.extractions?.models || {}
|
|
360
|
+
const model = models[seed.entity]
|
|
361
|
+
if (model?.devise_modules?.length > 0) return true
|
|
362
|
+
return false
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function deduplicateByHighestRisk(entities) {
|
|
366
|
+
const byEntity = new Map()
|
|
367
|
+
for (const entity of entities) {
|
|
368
|
+
const existing = byEntity.get(entity.entity)
|
|
369
|
+
if (
|
|
370
|
+
!existing ||
|
|
371
|
+
RISK_LEVELS.indexOf(entity.risk) < RISK_LEVELS.indexOf(existing.risk)
|
|
372
|
+
) {
|
|
373
|
+
byEntity.set(entity.entity, entity)
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return [...byEntity.values()]
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function sortByRisk(entities) {
|
|
380
|
+
return entities.sort((a, b) => {
|
|
381
|
+
const riskDiff = RISK_LEVELS.indexOf(a.risk) - RISK_LEVELS.indexOf(b.risk)
|
|
382
|
+
if (riskDiff !== 0) return riskDiff
|
|
383
|
+
return a.distance - b.distance
|
|
384
|
+
})
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function collectImpactedTests(impacted, seeds, graph, index) {
|
|
388
|
+
const tests = []
|
|
389
|
+
const seen = new Set()
|
|
390
|
+
const allEntityIds = [
|
|
391
|
+
...seeds.map((s) => s.entity),
|
|
392
|
+
...impacted.map((e) => e.entity),
|
|
393
|
+
]
|
|
394
|
+
|
|
395
|
+
for (const edge of graph.edges) {
|
|
396
|
+
if (edge.type !== 'tests') continue
|
|
397
|
+
if (allEntityIds.includes(edge.to) && !seen.has(edge.from)) {
|
|
398
|
+
seen.add(edge.from)
|
|
399
|
+
const fileEntityMap = index.fileEntityMap || {}
|
|
400
|
+
const reverseMap = buildReverseEntityFileMap(fileEntityMap)
|
|
401
|
+
tests.push({
|
|
402
|
+
path: reverseMap[edge.from] || edge.from,
|
|
403
|
+
entity: edge.from,
|
|
404
|
+
covers: edge.to,
|
|
405
|
+
})
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return tests
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function buildSummary(impacted) {
|
|
413
|
+
const summary = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0, total: 0 }
|
|
414
|
+
for (const entity of impacted) {
|
|
415
|
+
summary[entity.risk] = (summary[entity.risk] || 0) + 1
|
|
416
|
+
summary.total++
|
|
417
|
+
}
|
|
418
|
+
return summary
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function groupByRisk(impacted) {
|
|
422
|
+
const grouped = {}
|
|
423
|
+
for (const entity of impacted) {
|
|
424
|
+
if (!grouped[entity.risk]) grouped[entity.risk] = []
|
|
425
|
+
grouped[entity.risk].push(entity)
|
|
426
|
+
}
|
|
427
|
+
return grouped
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function buildEntitySummary(entity, index) {
|
|
431
|
+
const extractions = index.extractions || {}
|
|
432
|
+
const detail = lookupEntityDetail(entity.entity, entity.type, extractions)
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
entity: entity.entity,
|
|
436
|
+
type: entity.type,
|
|
437
|
+
risk: entity.risk,
|
|
438
|
+
distance: entity.distance,
|
|
439
|
+
reason: entity.reason,
|
|
440
|
+
file: entity.file,
|
|
441
|
+
summary: formatStructuralSummary(entity.entity, entity.type, detail),
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function compactEntitySummary(entity) {
|
|
446
|
+
return {
|
|
447
|
+
entity: entity.entity,
|
|
448
|
+
type: entity.type,
|
|
449
|
+
risk: entity.risk,
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function lookupEntityDetail(entityName, entityType, extractions) {
|
|
454
|
+
if (entityType === 'model') return extractions.models?.[entityName]
|
|
455
|
+
if (entityType === 'controller') return extractions.controllers?.[entityName]
|
|
456
|
+
if (entityType === 'component') return extractions.components?.[entityName]
|
|
457
|
+
return null
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function formatStructuralSummary(name, type, detail) {
|
|
461
|
+
if (!detail) return `${name} (${type})`
|
|
462
|
+
|
|
463
|
+
if (type === 'model') return formatModelSummary(name, detail)
|
|
464
|
+
if (type === 'controller') return formatControllerSummary(name, detail)
|
|
465
|
+
if (type === 'component') return formatComponentSummary(name, detail)
|
|
466
|
+
return `${name} (${type})`
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function formatModelSummary(name, model) {
|
|
470
|
+
const parts = [name]
|
|
471
|
+
const assocCount = (model.associations || []).length
|
|
472
|
+
if (assocCount > 0) parts.push(`${assocCount} associations`)
|
|
473
|
+
if (model.has_secure_password) parts.push('has_secure_password')
|
|
474
|
+
const scopeCount = (model.scopes || []).length
|
|
475
|
+
if (scopeCount > 0) parts.push(`${scopeCount} scopes`)
|
|
476
|
+
const callbackCount = (model.callbacks || []).length
|
|
477
|
+
if (callbackCount > 0) parts.push(`${callbackCount} callbacks`)
|
|
478
|
+
return parts.join(' — ')
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function formatControllerSummary(name, controller) {
|
|
482
|
+
const parts = [name]
|
|
483
|
+
const actionCount = (controller.actions || []).length
|
|
484
|
+
if (actionCount > 0) parts.push(`${actionCount} actions`)
|
|
485
|
+
const filters = controller.before_actions || controller.filters || []
|
|
486
|
+
if (filters.length > 0) parts.push(filters.map((f) => f.name || f).join(', '))
|
|
487
|
+
return parts.join(' — ')
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function formatComponentSummary(name, component) {
|
|
491
|
+
const parts = [name]
|
|
492
|
+
const slotCount = (component.slots || []).length
|
|
493
|
+
if (slotCount > 0) parts.push(`${slotCount} slots`)
|
|
494
|
+
if (component.has_preview) parts.push('has preview')
|
|
495
|
+
return parts.join(' — ')
|
|
496
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared constants used across tools and core modules.
|
|
3
|
+
* Avoids magic numbers scattered through the codebase.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Default token budget for blast radius / review context responses. */
|
|
7
|
+
export const DEFAULT_TOKEN_BUDGET = 8000
|
|
8
|
+
|
|
9
|
+
/** Default token budget for full index output. */
|
|
10
|
+
export const DEFAULT_FULL_INDEX_BUDGET = 12000
|
|
11
|
+
|
|
12
|
+
/** Maximum key models/controllers shown in overview. */
|
|
13
|
+
export const MAX_KEY_ENTITIES = 8
|
|
14
|
+
|
|
15
|
+
/** Maximum character length for example spec content. */
|
|
16
|
+
export const MAX_EXAMPLE_CONTENT_LENGTH = 5000
|
|
17
|
+
|
|
18
|
+
/** Coverage percentage at or above which a file is considered well-covered. */
|
|
19
|
+
export const WELL_COVERED_THRESHOLD = 90
|
|
20
|
+
|
|
21
|
+
/** Maximum buffer size (bytes) for shell command execution. */
|
|
22
|
+
export const EXEC_MAX_BUFFER = 1024 * 1024
|
|
23
|
+
|
|
24
|
+
/** Timeout (ms) for shell command execution. */
|
|
25
|
+
export const EXEC_TIMEOUT_MS = 10000
|
|
26
|
+
|
|
27
|
+
/** Precision multiplier for PageRank score rounding. */
|
|
28
|
+
export const RANK_PRECISION = 10000
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Round to one decimal percentage: (numerator / denominator) as XX.X%.
|
|
32
|
+
* @param {number} numerator
|
|
33
|
+
* @param {number} denominator
|
|
34
|
+
* @returns {number|null}
|
|
35
|
+
*/
|
|
36
|
+
export function toOneDecimalPercent(numerator, denominator) {
|
|
37
|
+
if (denominator <= 0) return null
|
|
38
|
+
return Math.round((numerator / denominator) * 1000) / 10
|
|
39
|
+
}
|