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.
@@ -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
- // Partition functions by classification
56
- const pureFunctions = functions.filter(f => f.classification === 'pure')
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 (total === 0) {
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
- // Calculate purity (percentage of pure functions)
83
- const purity = (pureCount / total) * 100
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 (average quality of impure functions)
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 totalQuality = impureFunctions.reduce(
89
- (sum, f) => sum + (f.qualityScore ?? 0),
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 breakdown
96
- const statusBreakdown: 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 (functions with status 'refactor')
370
+ // Get refactoring candidates from effectively impure top-level functions
116
371
  // Sorted by impact: bodyLineCount × (100 - qualityScore) descending
117
- const refactoringCandidates = impureFunctions
118
- .filter(f => f.status === 'refactor')
119
- .map(f => ({
120
- name: f.name,
121
- startLine: f.startLine,
122
- bodyLineCount: f.bodyLineCount,
123
- qualityScore: f.qualityScore ?? 0,
124
- markers: f.markers.map(m => m.type),
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
- purity: 100,
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
- // Calculate totals
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
- purity,
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
- purity: 100,
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
- // Calculate totals
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
- purity,
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
  *