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