fcis 0.1.0 → 0.2.0
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/.plans/003-code-cleanup-consolidation.md +242 -0
- package/.plans/004-directory-depth-rollup.md +408 -0
- package/.plans/005-code-refinements.md +210 -0
- package/.plans/006-minor-refinements.md +149 -0
- package/.plans/007-compositional-function-scoring.md +514 -0
- package/README.md +38 -3
- package/TECHNICAL.md +125 -2
- package/dist/cli.js +595 -327
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +15 -2
- package/dist/index.js +409 -240
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/cli-utils.ts +201 -0
- package/src/cli.ts +99 -117
- package/src/detection/markers.ts +0 -222
- package/src/extraction/extract-functions.ts +106 -2
- package/src/extraction/extractor.ts +35 -74
- package/src/reporting/report-console.ts +188 -102
- package/src/reporting/report-json.ts +26 -3
- package/src/scoring/scorer.ts +425 -160
- package/src/types.ts +9 -2
- package/tests/classifier.test.ts +0 -1
- package/tests/cli.test.ts +356 -0
- package/tests/detect-markers.test.ts +1 -3
- package/tests/extractor.test.ts +95 -1
- package/tests/integration.test.ts +344 -0
- package/tests/report-console.test.ts +92 -0
- package/tests/scorer.test.ts +886 -0
package/src/scoring/scorer.ts
CHANGED
|
@@ -6,9 +6,14 @@
|
|
|
6
6
|
* all functions take data in and return data out with no I/O.
|
|
7
7
|
*
|
|
8
8
|
* Metrics:
|
|
9
|
-
* - Purity: percentage of pure functions
|
|
9
|
+
* - Purity: percentage of pure functions (top-level only, excluding inline callbacks)
|
|
10
10
|
* - Impurity Quality: average quality score of impure functions
|
|
11
11
|
* - Health: percentage of functions with status 'ok'
|
|
12
|
+
*
|
|
13
|
+
* Compositional Scoring (Plan 007):
|
|
14
|
+
* - Inline callbacks are absorbed into their parent function's score
|
|
15
|
+
* - Function counts only include top-level functions
|
|
16
|
+
* - Line counts don't double-count (parent bodyLineCount includes nested callbacks)
|
|
12
17
|
*/
|
|
13
18
|
|
|
14
19
|
import type {
|
|
@@ -20,6 +25,250 @@ import type {
|
|
|
20
25
|
StatusBreakdown,
|
|
21
26
|
} from '../types.js'
|
|
22
27
|
|
|
28
|
+
// ============================================================================
|
|
29
|
+
// Compositional Scoring Helpers
|
|
30
|
+
// ============================================================================
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Represents a function with its absorbed inline callbacks
|
|
34
|
+
*/
|
|
35
|
+
type FunctionWithChildren = {
|
|
36
|
+
fn: ClassifiedFunction
|
|
37
|
+
children: ClassifiedFunction[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Group functions by their parent, building a tree structure.
|
|
42
|
+
* Top-level functions (isInlineCallback=false OR enclosingFunctionStartLine=null)
|
|
43
|
+
* become roots; inline callbacks become children of their enclosing function.
|
|
44
|
+
*
|
|
45
|
+
* @param functions - All classified functions in a file
|
|
46
|
+
* @returns Array of top-level functions with their children
|
|
47
|
+
*/
|
|
48
|
+
export function groupFunctionsByParent(
|
|
49
|
+
functions: ClassifiedFunction[],
|
|
50
|
+
): FunctionWithChildren[] {
|
|
51
|
+
// Build a map of startLine -> function for quick lookup
|
|
52
|
+
const byStartLine = new Map<number, ClassifiedFunction>()
|
|
53
|
+
for (const fn of functions) {
|
|
54
|
+
byStartLine.set(fn.startLine, fn)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Separate top-level functions from inline callbacks with enclosing functions
|
|
58
|
+
const topLevel: FunctionWithChildren[] = []
|
|
59
|
+
const childrenByParentLine = new Map<number, ClassifiedFunction[]>()
|
|
60
|
+
|
|
61
|
+
for (const fn of functions) {
|
|
62
|
+
// A function is top-level if:
|
|
63
|
+
// 1. It's not an inline callback, OR
|
|
64
|
+
// 2. It's an inline callback but at module scope (no enclosing function)
|
|
65
|
+
const isTopLevel =
|
|
66
|
+
!fn.isInlineCallback || fn.enclosingFunctionStartLine === null
|
|
67
|
+
|
|
68
|
+
if (isTopLevel) {
|
|
69
|
+
topLevel.push({ fn, children: [] })
|
|
70
|
+
} else if (fn.enclosingFunctionStartLine !== null) {
|
|
71
|
+
// This is an inline callback with an enclosing function
|
|
72
|
+
const existing = childrenByParentLine.get(fn.enclosingFunctionStartLine)
|
|
73
|
+
if (existing) {
|
|
74
|
+
existing.push(fn)
|
|
75
|
+
} else {
|
|
76
|
+
childrenByParentLine.set(fn.enclosingFunctionStartLine, [fn])
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Attach children to their parents
|
|
82
|
+
for (const entry of topLevel) {
|
|
83
|
+
const children = childrenByParentLine.get(entry.fn.startLine)
|
|
84
|
+
if (children) {
|
|
85
|
+
entry.children = children
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return topLevel
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Check if a function or any of its children is impure
|
|
94
|
+
*/
|
|
95
|
+
function isEffectivelyImpure(entry: FunctionWithChildren): boolean {
|
|
96
|
+
if (entry.fn.classification === 'impure') return true
|
|
97
|
+
return entry.children.some(child => child.classification === 'impure')
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Get the effective status for a function considering its children.
|
|
102
|
+
* The worst status among the function and its children wins.
|
|
103
|
+
*/
|
|
104
|
+
function getEffectiveStatus(
|
|
105
|
+
entry: FunctionWithChildren,
|
|
106
|
+
): 'ok' | 'review' | 'refactor' {
|
|
107
|
+
const statuses = [entry.fn.status, ...entry.children.map(c => c.status)]
|
|
108
|
+
|
|
109
|
+
if (statuses.includes('refactor')) return 'refactor'
|
|
110
|
+
if (statuses.includes('review')) return 'review'
|
|
111
|
+
return 'ok'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Compute the blended quality score for a function with children.
|
|
116
|
+
* Uses line-count-weighted average of parent and impure children.
|
|
117
|
+
*
|
|
118
|
+
* Formula:
|
|
119
|
+
* composedQuality = (parentQuality × parentOwnLines + Σ(childQuality × childLines)) / totalLines
|
|
120
|
+
*
|
|
121
|
+
* Where parentOwnLines = parent.bodyLineCount - Σ(child.bodyLineCount), clamped to min 1
|
|
122
|
+
*
|
|
123
|
+
* If all children are pure, returns the parent's quality unchanged.
|
|
124
|
+
* If parent is pure but has impure children, returns weighted avg of children only.
|
|
125
|
+
*/
|
|
126
|
+
function computeComposedQuality(entry: FunctionWithChildren): number {
|
|
127
|
+
const impureChildren = entry.children.filter(
|
|
128
|
+
c => c.classification === 'impure',
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
// If no impure children, use parent's quality (or default if parent is pure)
|
|
132
|
+
if (impureChildren.length === 0) {
|
|
133
|
+
return entry.fn.qualityScore ?? 50
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Calculate child line counts
|
|
137
|
+
const childTotalLines = entry.children.reduce(
|
|
138
|
+
(sum, c) => sum + c.bodyLineCount,
|
|
139
|
+
0,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
// Parent's own lines (excluding nested callbacks), clamped to at least 1
|
|
143
|
+
const parentOwnLines = Math.max(1, entry.fn.bodyLineCount - childTotalLines)
|
|
144
|
+
|
|
145
|
+
// Total lines for weighting
|
|
146
|
+
const totalLines = parentOwnLines + childTotalLines
|
|
147
|
+
|
|
148
|
+
// Parent's quality contribution (use 50 as default if pure parent with impure children)
|
|
149
|
+
const parentQuality = entry.fn.qualityScore ?? 50
|
|
150
|
+
const parentContribution = parentQuality * parentOwnLines
|
|
151
|
+
|
|
152
|
+
// Children's quality contribution (only impure children affect impurity quality)
|
|
153
|
+
const childContribution = impureChildren.reduce(
|
|
154
|
+
(sum, c) => sum + (c.qualityScore ?? 50) * c.bodyLineCount,
|
|
155
|
+
0,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
// Weighted blend
|
|
159
|
+
return (parentContribution + childContribution) / totalLines
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Common shape for items that can be aggregated
|
|
164
|
+
*/
|
|
165
|
+
type Aggregatable = {
|
|
166
|
+
pureCount: number
|
|
167
|
+
impureCount: number
|
|
168
|
+
impurityQuality: number | null
|
|
169
|
+
statusBreakdown: StatusBreakdown
|
|
170
|
+
pureLineCount: number
|
|
171
|
+
impureLineCount: number
|
|
172
|
+
excludedCount: number
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Result of aggregating metrics from multiple items
|
|
177
|
+
*/
|
|
178
|
+
type AggregatedMetrics = {
|
|
179
|
+
purity: number
|
|
180
|
+
impurityQuality: number | null
|
|
181
|
+
health: number
|
|
182
|
+
pureCount: number
|
|
183
|
+
impureCount: number
|
|
184
|
+
excludedCount: number
|
|
185
|
+
statusBreakdown: StatusBreakdown
|
|
186
|
+
pureLineCount: number
|
|
187
|
+
impureLineCount: number
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Aggregate metrics from multiple items using weighted averages
|
|
192
|
+
*
|
|
193
|
+
* @param items - Array of items with metric counts
|
|
194
|
+
* @returns Aggregated metrics with weighted averages
|
|
195
|
+
*/
|
|
196
|
+
export function aggregateMetrics<T extends Aggregatable>(
|
|
197
|
+
items: T[],
|
|
198
|
+
): AggregatedMetrics {
|
|
199
|
+
// Filter to items with actual functions
|
|
200
|
+
const scorableItems = items.filter(
|
|
201
|
+
item => item.pureCount + item.impureCount > 0,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
// Calculate totals
|
|
205
|
+
const totalPure = items.reduce((sum, item) => sum + item.pureCount, 0)
|
|
206
|
+
const totalImpure = items.reduce((sum, item) => sum + item.impureCount, 0)
|
|
207
|
+
const total = totalPure + totalImpure
|
|
208
|
+
|
|
209
|
+
// Handle empty case
|
|
210
|
+
if (total === 0) {
|
|
211
|
+
return {
|
|
212
|
+
purity: 100,
|
|
213
|
+
impurityQuality: null,
|
|
214
|
+
health: 100,
|
|
215
|
+
pureCount: 0,
|
|
216
|
+
impureCount: 0,
|
|
217
|
+
excludedCount: items.reduce((sum, item) => sum + item.excludedCount, 0),
|
|
218
|
+
statusBreakdown: { ok: 0, review: 0, refactor: 0 },
|
|
219
|
+
pureLineCount: 0,
|
|
220
|
+
impureLineCount: 0,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Weighted purity by function count
|
|
225
|
+
const purity = (totalPure / total) * 100
|
|
226
|
+
|
|
227
|
+
// Weighted impurity quality
|
|
228
|
+
let impurityQuality: number | null = null
|
|
229
|
+
if (totalImpure > 0) {
|
|
230
|
+
const weightedQuality = scorableItems.reduce((sum, item) => {
|
|
231
|
+
if (item.impurityQuality !== null && item.impureCount > 0) {
|
|
232
|
+
return sum + item.impurityQuality * item.impureCount
|
|
233
|
+
}
|
|
234
|
+
return sum
|
|
235
|
+
}, 0)
|
|
236
|
+
impurityQuality = weightedQuality / totalImpure
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Aggregate status breakdown
|
|
240
|
+
const statusBreakdown: StatusBreakdown = {
|
|
241
|
+
ok: items.reduce((sum, item) => sum + item.statusBreakdown.ok, 0),
|
|
242
|
+
review: items.reduce((sum, item) => sum + item.statusBreakdown.review, 0),
|
|
243
|
+
refactor: items.reduce(
|
|
244
|
+
(sum, item) => sum + item.statusBreakdown.refactor,
|
|
245
|
+
0,
|
|
246
|
+
),
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Calculate health (percentage with status 'ok')
|
|
250
|
+
const health = (statusBreakdown.ok / total) * 100
|
|
251
|
+
|
|
252
|
+
// Aggregate line counts
|
|
253
|
+
const pureLineCount = items.reduce((sum, item) => sum + item.pureLineCount, 0)
|
|
254
|
+
const impureLineCount = items.reduce(
|
|
255
|
+
(sum, item) => sum + item.impureLineCount,
|
|
256
|
+
0,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
purity,
|
|
261
|
+
impurityQuality,
|
|
262
|
+
health,
|
|
263
|
+
pureCount: totalPure,
|
|
264
|
+
impureCount: totalImpure,
|
|
265
|
+
excludedCount: items.reduce((sum, item) => sum + item.excludedCount, 0),
|
|
266
|
+
statusBreakdown,
|
|
267
|
+
pureLineCount,
|
|
268
|
+
impureLineCount,
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
23
272
|
/**
|
|
24
273
|
* Score a single file based on its classified functions
|
|
25
274
|
*
|
|
@@ -52,16 +301,11 @@ export function scoreFile(
|
|
|
52
301
|
}
|
|
53
302
|
}
|
|
54
303
|
|
|
55
|
-
//
|
|
56
|
-
const
|
|
57
|
-
const impureFunctions = functions.filter(f => f.classification === 'impure')
|
|
58
|
-
|
|
59
|
-
const pureCount = pureFunctions.length
|
|
60
|
-
const impureCount = impureFunctions.length
|
|
61
|
-
const total = pureCount + impureCount
|
|
304
|
+
// Group functions by parent for compositional scoring
|
|
305
|
+
const grouped = groupFunctionsByParent(functions)
|
|
62
306
|
|
|
63
307
|
// Handle empty files
|
|
64
|
-
if (
|
|
308
|
+
if (grouped.length === 0) {
|
|
65
309
|
return {
|
|
66
310
|
filePath,
|
|
67
311
|
purity: 100,
|
|
@@ -79,50 +323,72 @@ export function scoreFile(
|
|
|
79
323
|
}
|
|
80
324
|
}
|
|
81
325
|
|
|
82
|
-
//
|
|
83
|
-
|
|
326
|
+
// Count top-level functions by effective classification
|
|
327
|
+
// A function is effectively impure if it or any of its children is impure
|
|
328
|
+
let pureCount = 0
|
|
329
|
+
let impureCount = 0
|
|
330
|
+
let pureLineCount = 0
|
|
331
|
+
let impureLineCount = 0
|
|
332
|
+
const statusBreakdown: StatusBreakdown = { ok: 0, review: 0, refactor: 0 }
|
|
333
|
+
|
|
334
|
+
for (const entry of grouped) {
|
|
335
|
+
const effectivelyImpure = isEffectivelyImpure(entry)
|
|
336
|
+
const effectiveStatus = getEffectiveStatus(entry)
|
|
337
|
+
|
|
338
|
+
if (effectivelyImpure) {
|
|
339
|
+
impureCount++
|
|
340
|
+
// Use parent's bodyLineCount (already includes children)
|
|
341
|
+
impureLineCount += entry.fn.bodyLineCount
|
|
342
|
+
} else {
|
|
343
|
+
pureCount++
|
|
344
|
+
pureLineCount += entry.fn.bodyLineCount
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
statusBreakdown[effectiveStatus]++
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const total = pureCount + impureCount
|
|
351
|
+
|
|
352
|
+
// Calculate purity (percentage of effectively pure top-level functions)
|
|
353
|
+
const purity = total > 0 ? (pureCount / total) * 100 : 100
|
|
84
354
|
|
|
85
|
-
// Calculate impurity quality
|
|
355
|
+
// Calculate impurity quality using composed quality scores
|
|
356
|
+
// Each effectively impure function's quality is blended with its impure children
|
|
86
357
|
let impurityQuality: number | null = null
|
|
87
358
|
if (impureCount > 0) {
|
|
88
|
-
const
|
|
89
|
-
|
|
359
|
+
const impureEntries = grouped.filter(e => isEffectivelyImpure(e))
|
|
360
|
+
const totalQuality = impureEntries.reduce(
|
|
361
|
+
(sum, e) => sum + computeComposedQuality(e),
|
|
90
362
|
0,
|
|
91
363
|
)
|
|
92
364
|
impurityQuality = totalQuality / impureCount
|
|
93
365
|
}
|
|
94
366
|
|
|
95
|
-
// Calculate status
|
|
96
|
-
const statusBreakdown
|
|
97
|
-
ok: functions.filter(f => f.status === 'ok').length,
|
|
98
|
-
review: functions.filter(f => f.status === 'review').length,
|
|
99
|
-
refactor: functions.filter(f => f.status === 'refactor').length,
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
// Calculate health (percentage with status 'ok')
|
|
103
|
-
const health = (statusBreakdown.ok / total) * 100
|
|
104
|
-
|
|
105
|
-
// Calculate line counts
|
|
106
|
-
const pureLineCount = pureFunctions.reduce(
|
|
107
|
-
(sum, f) => sum + f.bodyLineCount,
|
|
108
|
-
0,
|
|
109
|
-
)
|
|
110
|
-
const impureLineCount = impureFunctions.reduce(
|
|
111
|
-
(sum, f) => sum + f.bodyLineCount,
|
|
112
|
-
0,
|
|
113
|
-
)
|
|
367
|
+
// Calculate health (percentage with effective status 'ok')
|
|
368
|
+
const health = total > 0 ? (statusBreakdown.ok / total) * 100 : 100
|
|
114
369
|
|
|
115
|
-
// Get refactoring candidates
|
|
370
|
+
// Get refactoring candidates from effectively impure top-level functions
|
|
116
371
|
// Sorted by impact: bodyLineCount × (100 - qualityScore) descending
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
372
|
+
// Markers are aggregated from parent AND children for comprehensive reporting
|
|
373
|
+
const refactoringCandidates = grouped
|
|
374
|
+
.filter(e => isEffectivelyImpure(e) && getEffectiveStatus(e) === 'refactor')
|
|
375
|
+
.map(e => {
|
|
376
|
+
// Aggregate markers from parent and all children
|
|
377
|
+
const allMarkers = [
|
|
378
|
+
...e.fn.markers.map(m => m.type),
|
|
379
|
+
...e.children.flatMap(c => c.markers.map(m => m.type)),
|
|
380
|
+
]
|
|
381
|
+
// Dedupe markers (same type from multiple children)
|
|
382
|
+
const uniqueMarkers = [...new Set(allMarkers)]
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
name: e.fn.name,
|
|
386
|
+
startLine: e.fn.startLine,
|
|
387
|
+
bodyLineCount: e.fn.bodyLineCount,
|
|
388
|
+
qualityScore: e.fn.qualityScore ?? 0,
|
|
389
|
+
markers: uniqueMarkers,
|
|
390
|
+
}
|
|
391
|
+
})
|
|
126
392
|
.sort((a, b) => {
|
|
127
393
|
const impactA = a.bodyLineCount * (100 - a.qualityScore)
|
|
128
394
|
const impactB = b.bodyLineCount * (100 - b.qualityScore)
|
|
@@ -140,7 +406,7 @@ export function scoreFile(
|
|
|
140
406
|
statusBreakdown,
|
|
141
407
|
pureLineCount,
|
|
142
408
|
impureLineCount,
|
|
143
|
-
functions,
|
|
409
|
+
functions, // Keep all functions for drill-down (includes isInlineCallback flag)
|
|
144
410
|
refactoringCandidates,
|
|
145
411
|
}
|
|
146
412
|
}
|
|
@@ -162,75 +428,21 @@ export function scoreDirectory(
|
|
|
162
428
|
)
|
|
163
429
|
|
|
164
430
|
if (scorableFiles.length === 0) {
|
|
431
|
+
const emptyMetrics = aggregateMetrics([])
|
|
165
432
|
return {
|
|
166
433
|
dirPath,
|
|
167
|
-
|
|
168
|
-
impurityQuality: null,
|
|
169
|
-
health: 100,
|
|
170
|
-
pureCount: 0,
|
|
171
|
-
impureCount: 0,
|
|
434
|
+
...emptyMetrics,
|
|
172
435
|
excludedCount: fileScores.reduce((sum, f) => sum + f.excludedCount, 0),
|
|
173
|
-
statusBreakdown: { ok: 0, review: 0, refactor: 0 },
|
|
174
|
-
pureLineCount: 0,
|
|
175
|
-
impureLineCount: 0,
|
|
176
436
|
fileScores,
|
|
177
437
|
}
|
|
178
438
|
}
|
|
179
439
|
|
|
180
|
-
|
|
181
|
-
const totalPure = scorableFiles.reduce((sum, f) => sum + f.pureCount, 0)
|
|
182
|
-
const totalImpure = scorableFiles.reduce((sum, f) => sum + f.impureCount, 0)
|
|
183
|
-
const total = totalPure + totalImpure
|
|
184
|
-
|
|
185
|
-
// Weighted purity by function count
|
|
186
|
-
const purity = total > 0 ? (totalPure / total) * 100 : 100
|
|
187
|
-
|
|
188
|
-
// Weighted impurity quality
|
|
189
|
-
let impurityQuality: number | null = null
|
|
190
|
-
if (totalImpure > 0) {
|
|
191
|
-
const weightedQuality = scorableFiles.reduce((sum, f) => {
|
|
192
|
-
if (f.impurityQuality !== null && f.impureCount > 0) {
|
|
193
|
-
return sum + f.impurityQuality * f.impureCount
|
|
194
|
-
}
|
|
195
|
-
return sum
|
|
196
|
-
}, 0)
|
|
197
|
-
impurityQuality = weightedQuality / totalImpure
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Aggregate status breakdown
|
|
201
|
-
const statusBreakdown: StatusBreakdown = {
|
|
202
|
-
ok: scorableFiles.reduce((sum, f) => sum + f.statusBreakdown.ok, 0),
|
|
203
|
-
review: scorableFiles.reduce((sum, f) => sum + f.statusBreakdown.review, 0),
|
|
204
|
-
refactor: scorableFiles.reduce(
|
|
205
|
-
(sum, f) => sum + f.statusBreakdown.refactor,
|
|
206
|
-
0,
|
|
207
|
-
),
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
// Calculate health
|
|
211
|
-
const health = total > 0 ? (statusBreakdown.ok / total) * 100 : 100
|
|
212
|
-
|
|
213
|
-
// Aggregate line counts
|
|
214
|
-
const pureLineCount = scorableFiles.reduce(
|
|
215
|
-
(sum, f) => sum + f.pureLineCount,
|
|
216
|
-
0,
|
|
217
|
-
)
|
|
218
|
-
const impureLineCount = scorableFiles.reduce(
|
|
219
|
-
(sum, f) => sum + f.impureLineCount,
|
|
220
|
-
0,
|
|
221
|
-
)
|
|
440
|
+
const aggregated = aggregateMetrics(scorableFiles)
|
|
222
441
|
|
|
223
442
|
return {
|
|
224
443
|
dirPath,
|
|
225
|
-
|
|
226
|
-
impurityQuality,
|
|
227
|
-
health,
|
|
228
|
-
pureCount: totalPure,
|
|
229
|
-
impureCount: totalImpure,
|
|
444
|
+
...aggregated,
|
|
230
445
|
excludedCount: fileScores.reduce((sum, f) => sum + f.excludedCount, 0),
|
|
231
|
-
statusBreakdown,
|
|
232
|
-
pureLineCount,
|
|
233
|
-
impureLineCount,
|
|
234
446
|
fileScores,
|
|
235
447
|
}
|
|
236
448
|
}
|
|
@@ -266,19 +478,13 @@ export function scoreProject(
|
|
|
266
478
|
)
|
|
267
479
|
|
|
268
480
|
if (scorableDirs.length === 0) {
|
|
481
|
+
const emptyMetrics = aggregateMetrics([])
|
|
269
482
|
return {
|
|
270
|
-
|
|
271
|
-
impurityQuality: null,
|
|
272
|
-
health: 100,
|
|
273
|
-
pureCount: 0,
|
|
274
|
-
impureCount: 0,
|
|
483
|
+
...emptyMetrics,
|
|
275
484
|
excludedCount: directoryScores.reduce(
|
|
276
485
|
(sum, d) => sum + d.excludedCount,
|
|
277
486
|
0,
|
|
278
487
|
),
|
|
279
|
-
statusBreakdown: { ok: 0, review: 0, refactor: 0 },
|
|
280
|
-
pureLineCount: 0,
|
|
281
|
-
impureLineCount: 0,
|
|
282
488
|
directoryScores,
|
|
283
489
|
timestamp,
|
|
284
490
|
commitHash,
|
|
@@ -289,48 +495,7 @@ export function scoreProject(
|
|
|
289
495
|
}
|
|
290
496
|
}
|
|
291
497
|
|
|
292
|
-
|
|
293
|
-
const totalPure = scorableDirs.reduce((sum, d) => sum + d.pureCount, 0)
|
|
294
|
-
const totalImpure = scorableDirs.reduce((sum, d) => sum + d.impureCount, 0)
|
|
295
|
-
const total = totalPure + totalImpure
|
|
296
|
-
|
|
297
|
-
// Weighted purity by function count
|
|
298
|
-
const purity = total > 0 ? (totalPure / total) * 100 : 100
|
|
299
|
-
|
|
300
|
-
// Weighted impurity quality
|
|
301
|
-
let impurityQuality: number | null = null
|
|
302
|
-
if (totalImpure > 0) {
|
|
303
|
-
const weightedQuality = scorableDirs.reduce((sum, d) => {
|
|
304
|
-
if (d.impurityQuality !== null && d.impureCount > 0) {
|
|
305
|
-
return sum + d.impurityQuality * d.impureCount
|
|
306
|
-
}
|
|
307
|
-
return sum
|
|
308
|
-
}, 0)
|
|
309
|
-
impurityQuality = weightedQuality / totalImpure
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Aggregate status breakdown
|
|
313
|
-
const statusBreakdown: StatusBreakdown = {
|
|
314
|
-
ok: scorableDirs.reduce((sum, d) => sum + d.statusBreakdown.ok, 0),
|
|
315
|
-
review: scorableDirs.reduce((sum, d) => sum + d.statusBreakdown.review, 0),
|
|
316
|
-
refactor: scorableDirs.reduce(
|
|
317
|
-
(sum, d) => sum + d.statusBreakdown.refactor,
|
|
318
|
-
0,
|
|
319
|
-
),
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
// Calculate health
|
|
323
|
-
const health = total > 0 ? (statusBreakdown.ok / total) * 100 : 100
|
|
324
|
-
|
|
325
|
-
// Aggregate line counts
|
|
326
|
-
const pureLineCount = scorableDirs.reduce(
|
|
327
|
-
(sum, d) => sum + d.pureLineCount,
|
|
328
|
-
0,
|
|
329
|
-
)
|
|
330
|
-
const impureLineCount = scorableDirs.reduce(
|
|
331
|
-
(sum, d) => sum + d.impureLineCount,
|
|
332
|
-
0,
|
|
333
|
-
)
|
|
498
|
+
const aggregated = aggregateMetrics(scorableDirs)
|
|
334
499
|
|
|
335
500
|
// Collect all refactoring candidates across all files
|
|
336
501
|
const allCandidates: RefactoringCandidate[] = []
|
|
@@ -353,15 +518,8 @@ export function scoreProject(
|
|
|
353
518
|
})
|
|
354
519
|
|
|
355
520
|
const result: ProjectScore = {
|
|
356
|
-
|
|
357
|
-
impurityQuality,
|
|
358
|
-
health,
|
|
359
|
-
pureCount: totalPure,
|
|
360
|
-
impureCount: totalImpure,
|
|
521
|
+
...aggregated,
|
|
361
522
|
excludedCount: directoryScores.reduce((sum, d) => sum + d.excludedCount, 0),
|
|
362
|
-
statusBreakdown,
|
|
363
|
-
pureLineCount,
|
|
364
|
-
impureLineCount,
|
|
365
523
|
directoryScores,
|
|
366
524
|
timestamp,
|
|
367
525
|
commitHash,
|
|
@@ -413,6 +571,113 @@ function getDirectoryPath(filePath: string): string {
|
|
|
413
571
|
return filePath.slice(0, lastSlash)
|
|
414
572
|
}
|
|
415
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Truncate a relative path to a specified depth
|
|
576
|
+
*
|
|
577
|
+
* @param relativePath - A path relative to project root (e.g., "src/services/auth")
|
|
578
|
+
* @param depth - The depth to truncate to (0 = first segment, 1 = first two segments, etc.)
|
|
579
|
+
* @returns The truncated path
|
|
580
|
+
*
|
|
581
|
+
* @example
|
|
582
|
+
* getPathAtDepth('src/services/auth/utils', 1) // 'src/services'
|
|
583
|
+
* getPathAtDepth('src/services/auth/utils', 0) // 'src'
|
|
584
|
+
* getPathAtDepth('src', 5) // 'src' (depth exceeds segments)
|
|
585
|
+
*/
|
|
586
|
+
export function getPathAtDepth(relativePath: string, depth: number): string {
|
|
587
|
+
// Normalize path separators to forward slashes and split
|
|
588
|
+
const normalized = relativePath.replace(/\\/g, '/')
|
|
589
|
+
const segments = normalized.split('/').filter(Boolean)
|
|
590
|
+
|
|
591
|
+
if (segments.length === 0) {
|
|
592
|
+
return '.'
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// depth 0 = 1 segment, depth 1 = 2 segments, etc.
|
|
596
|
+
const segmentCount = depth + 1
|
|
597
|
+
|
|
598
|
+
if (segmentCount >= segments.length) {
|
|
599
|
+
return segments.join('/')
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return segments.slice(0, segmentCount).join('/')
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Roll up directory scores to a specified depth
|
|
607
|
+
*
|
|
608
|
+
* Groups directories by their path truncated to the specified depth,
|
|
609
|
+
* then aggregates metrics for each group.
|
|
610
|
+
*
|
|
611
|
+
* NOTE: This is a PURE function. Directories must have relative paths
|
|
612
|
+
* (path relativization should happen in the shell layer before calling).
|
|
613
|
+
*
|
|
614
|
+
* @param directories - Array of directory scores with relative paths
|
|
615
|
+
* @param depth - The depth to roll up to (0 = top-level directories)
|
|
616
|
+
* @returns Rolled-up directory scores sorted alphabetically
|
|
617
|
+
*
|
|
618
|
+
* @example
|
|
619
|
+
* // Given directories with relative paths 'src/services/auth' and 'src/services/users'
|
|
620
|
+
* // with depth=1
|
|
621
|
+
* // Returns a single DirectoryScore for 'src/services' with aggregated metrics
|
|
622
|
+
*/
|
|
623
|
+
export function rollupDirectoriesByDepth(
|
|
624
|
+
directories: DirectoryScore[],
|
|
625
|
+
depth: number,
|
|
626
|
+
): DirectoryScore[] {
|
|
627
|
+
if (directories.length === 0) {
|
|
628
|
+
return []
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// Group directories by their truncated path
|
|
632
|
+
const groups = new Map<string, DirectoryScore[]>()
|
|
633
|
+
|
|
634
|
+
for (const dir of directories) {
|
|
635
|
+
// Truncate to specified depth (paths should already be relative)
|
|
636
|
+
const truncatedPath = getPathAtDepth(dir.dirPath, depth)
|
|
637
|
+
|
|
638
|
+
const existing = groups.get(truncatedPath)
|
|
639
|
+
if (existing) {
|
|
640
|
+
existing.push(dir)
|
|
641
|
+
} else {
|
|
642
|
+
groups.set(truncatedPath, [dir])
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Aggregate each group into a single DirectoryScore
|
|
647
|
+
const rolledUp: DirectoryScore[] = []
|
|
648
|
+
|
|
649
|
+
for (const [groupPath, groupDirs] of groups) {
|
|
650
|
+
// Filter to directories with actual functions for aggregation
|
|
651
|
+
const scorableDirs = groupDirs.filter(d => d.pureCount + d.impureCount > 0)
|
|
652
|
+
|
|
653
|
+
if (scorableDirs.length === 0) {
|
|
654
|
+
// Use aggregateMetrics for empty case to ensure consistency
|
|
655
|
+
const emptyMetrics = aggregateMetrics([])
|
|
656
|
+
rolledUp.push({
|
|
657
|
+
dirPath: groupPath,
|
|
658
|
+
...emptyMetrics,
|
|
659
|
+
excludedCount: groupDirs.reduce((sum, d) => sum + d.excludedCount, 0),
|
|
660
|
+
fileScores: [],
|
|
661
|
+
})
|
|
662
|
+
continue
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
const aggregated = aggregateMetrics(scorableDirs)
|
|
666
|
+
|
|
667
|
+
rolledUp.push({
|
|
668
|
+
dirPath: groupPath,
|
|
669
|
+
...aggregated,
|
|
670
|
+
excludedCount: groupDirs.reduce((sum, d) => sum + d.excludedCount, 0),
|
|
671
|
+
fileScores: [],
|
|
672
|
+
})
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Sort alphabetically by path
|
|
676
|
+
rolledUp.sort((a, b) => a.dirPath.localeCompare(b.dirPath))
|
|
677
|
+
|
|
678
|
+
return rolledUp
|
|
679
|
+
}
|
|
680
|
+
|
|
416
681
|
/**
|
|
417
682
|
* Calculate delta between two project scores
|
|
418
683
|
*
|