fcis 0.1.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.
Files changed (151) hide show
  1. package/.plans/001-fcis-analyzer.md +832 -0
  2. package/.plans/002-fcis-analyzer-improvements.md +205 -0
  3. package/README.md +272 -0
  4. package/TECHNICAL.md +386 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +1836 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/index.d.ts +709 -0
  9. package/dist/index.js +1845 -0
  10. package/dist/index.js.map +1 -0
  11. package/package.json +47 -0
  12. package/pnpm-workspace.yaml +0 -0
  13. package/src/analyzer.ts +266 -0
  14. package/src/classification/classifier.ts +156 -0
  15. package/src/classification/derive-status.ts +171 -0
  16. package/src/classification/quality-scorer.ts +481 -0
  17. package/src/cli.ts +286 -0
  18. package/src/detection/detect-markers.ts +480 -0
  19. package/src/detection/markers.ts +332 -0
  20. package/src/extraction/extract-functions.ts +570 -0
  21. package/src/extraction/extractor.ts +188 -0
  22. package/src/index.ts +111 -0
  23. package/src/reporting/report-console.ts +416 -0
  24. package/src/reporting/report-json.ts +232 -0
  25. package/src/scoring/scorer.ts +504 -0
  26. package/src/types.ts +248 -0
  27. package/tests/classifier.test.ts +480 -0
  28. package/tests/derive-status.test.ts +464 -0
  29. package/tests/detect-markers.test.ts +639 -0
  30. package/tests/extractor.test.ts +155 -0
  31. package/tests/integration.test.ts +706 -0
  32. package/tests/quality-scorer.test.ts +650 -0
  33. package/tests/scorer.test.ts +768 -0
  34. package/tsconfig.json +34 -0
  35. package/tsup.config.ts +17 -0
  36. package/vendor/ts-morph/.editorconfig +10 -0
  37. package/vendor/ts-morph/.gitattributes +11 -0
  38. package/vendor/ts-morph/.github/CODE_OF_CONDUCT.md +77 -0
  39. package/vendor/ts-morph/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
  40. package/vendor/ts-morph/.github/ISSUE_TEMPLATE/custom.md +4 -0
  41. package/vendor/ts-morph/.github/ISSUE_TEMPLATE/feature_request.md +18 -0
  42. package/vendor/ts-morph/.github/workflows/ci.yml +50 -0
  43. package/vendor/ts-morph/.github/workflows/publish.yml +53 -0
  44. package/vendor/ts-morph/.vscode/settings.json +10 -0
  45. package/vendor/ts-morph/CONTRIBUTING.md +23 -0
  46. package/vendor/ts-morph/DEVELOPMENT.md +32 -0
  47. package/vendor/ts-morph/LICENSE +21 -0
  48. package/vendor/ts-morph/deno.json +8 -0
  49. package/vendor/ts-morph/deno.lock +1233 -0
  50. package/vendor/ts-morph/docs/CNAME +1 -0
  51. package/vendor/ts-morph/docs/Gemfile +2 -0
  52. package/vendor/ts-morph/docs/_config.yml +5 -0
  53. package/vendor/ts-morph/docs/_layouts/default.html +159 -0
  54. package/vendor/ts-morph/docs/_script-templates/main.ts +116 -0
  55. package/vendor/ts-morph/docs/assets/css/style.scss +212 -0
  56. package/vendor/ts-morph/docs/details/ambient.md +38 -0
  57. package/vendor/ts-morph/docs/details/async.md +31 -0
  58. package/vendor/ts-morph/docs/details/classes.md +314 -0
  59. package/vendor/ts-morph/docs/details/comment-ranges.md +7 -0
  60. package/vendor/ts-morph/docs/details/comments.md +122 -0
  61. package/vendor/ts-morph/docs/details/decorators.md +119 -0
  62. package/vendor/ts-morph/docs/details/documentation.md +73 -0
  63. package/vendor/ts-morph/docs/details/enums.md +117 -0
  64. package/vendor/ts-morph/docs/details/exports.md +308 -0
  65. package/vendor/ts-morph/docs/details/expressions.md +46 -0
  66. package/vendor/ts-morph/docs/details/functions.md +150 -0
  67. package/vendor/ts-morph/docs/details/generators.md +27 -0
  68. package/vendor/ts-morph/docs/details/identifiers.md +79 -0
  69. package/vendor/ts-morph/docs/details/imports.md +191 -0
  70. package/vendor/ts-morph/docs/details/index.md +52 -0
  71. package/vendor/ts-morph/docs/details/initializers.md +40 -0
  72. package/vendor/ts-morph/docs/details/interfaces.md +218 -0
  73. package/vendor/ts-morph/docs/details/literals.md +20 -0
  74. package/vendor/ts-morph/docs/details/modifiers.md +38 -0
  75. package/vendor/ts-morph/docs/details/modules.md +113 -0
  76. package/vendor/ts-morph/docs/details/namespaces.md +7 -0
  77. package/vendor/ts-morph/docs/details/object-literal-expressions.md +106 -0
  78. package/vendor/ts-morph/docs/details/parameters.md +64 -0
  79. package/vendor/ts-morph/docs/details/signatures.md +41 -0
  80. package/vendor/ts-morph/docs/details/source-files.md +292 -0
  81. package/vendor/ts-morph/docs/details/type-aliases.md +34 -0
  82. package/vendor/ts-morph/docs/details/type-parameters.md +72 -0
  83. package/vendor/ts-morph/docs/details/types.md +254 -0
  84. package/vendor/ts-morph/docs/details/variables.md +110 -0
  85. package/vendor/ts-morph/docs/emitting.md +151 -0
  86. package/vendor/ts-morph/docs/index.md +25 -0
  87. package/vendor/ts-morph/docs/manipulation/code-writer.md +20 -0
  88. package/vendor/ts-morph/docs/manipulation/formatting.md +76 -0
  89. package/vendor/ts-morph/docs/manipulation/index.md +136 -0
  90. package/vendor/ts-morph/docs/manipulation/order.md +14 -0
  91. package/vendor/ts-morph/docs/manipulation/performance.md +222 -0
  92. package/vendor/ts-morph/docs/manipulation/removing.md +31 -0
  93. package/vendor/ts-morph/docs/manipulation/renaming.md +106 -0
  94. package/vendor/ts-morph/docs/manipulation/settings.md +76 -0
  95. package/vendor/ts-morph/docs/manipulation/structures.md +117 -0
  96. package/vendor/ts-morph/docs/manipulation/transforms.md +84 -0
  97. package/vendor/ts-morph/docs/metrics/performance.json +4 -0
  98. package/vendor/ts-morph/docs/navigation/ambient-modules.md +22 -0
  99. package/vendor/ts-morph/docs/navigation/compiler-nodes.md +82 -0
  100. package/vendor/ts-morph/docs/navigation/directories.md +287 -0
  101. package/vendor/ts-morph/docs/navigation/example.md +50 -0
  102. package/vendor/ts-morph/docs/navigation/finding-references.md +53 -0
  103. package/vendor/ts-morph/docs/navigation/getting-source-files.md +59 -0
  104. package/vendor/ts-morph/docs/navigation/images/getChildrenVsForEachChild.gif +0 -0
  105. package/vendor/ts-morph/docs/navigation/index.md +94 -0
  106. package/vendor/ts-morph/docs/navigation/language-service.md +23 -0
  107. package/vendor/ts-morph/docs/navigation/program.md +25 -0
  108. package/vendor/ts-morph/docs/navigation/type-checker.md +33 -0
  109. package/vendor/ts-morph/docs/setup/adding-source-files.md +145 -0
  110. package/vendor/ts-morph/docs/setup/ast-viewers.md +46 -0
  111. package/vendor/ts-morph/docs/setup/diagnostics.md +109 -0
  112. package/vendor/ts-morph/docs/setup/file-system.md +106 -0
  113. package/vendor/ts-morph/docs/setup/images/atom-ast.png +0 -0
  114. package/vendor/ts-morph/docs/setup/images/atom-ast_small.png +0 -0
  115. package/vendor/ts-morph/docs/setup/images/atom-command-palette.png +0 -0
  116. package/vendor/ts-morph/docs/setup/images/atom-file.png +0 -0
  117. package/vendor/ts-morph/docs/setup/images/ts-ast-viewer.png +0 -0
  118. package/vendor/ts-morph/docs/setup/index.md +94 -0
  119. package/vendor/ts-morph/docs/utilities.md +55 -0
  120. package/vendor/ts-morph/dprint.json +23 -0
  121. package/vendor/ts-morph/package.json +30 -0
  122. package/vendor/ts-morph/packages/bootstrap/LICENSE +21 -0
  123. package/vendor/ts-morph/packages/bootstrap/lib/ts-morph-bootstrap.d.ts +397 -0
  124. package/vendor/ts-morph/packages/bootstrap/package.json +46 -0
  125. package/vendor/ts-morph/packages/bootstrap/readme.md +200 -0
  126. package/vendor/ts-morph/packages/common/LICENSE +21 -0
  127. package/vendor/ts-morph/packages/common/lib/ts-morph-common.d.ts +1082 -0
  128. package/vendor/ts-morph/packages/common/lib/typescript.d.ts +11439 -0
  129. package/vendor/ts-morph/packages/common/package.json +65 -0
  130. package/vendor/ts-morph/packages/common/readme.md +5 -0
  131. package/vendor/ts-morph/packages/scripts/changeTypeScriptVersion.ts +28 -0
  132. package/vendor/ts-morph/packages/scripts/createDeclarationProject.ts +47 -0
  133. package/vendor/ts-morph/packages/scripts/deps.ts +2 -0
  134. package/vendor/ts-morph/packages/scripts/execScript.ts +31 -0
  135. package/vendor/ts-morph/packages/scripts/folders.ts +11 -0
  136. package/vendor/ts-morph/packages/scripts/getDevCompilerVersions.ts +19 -0
  137. package/vendor/ts-morph/packages/scripts/mod.ts +7 -0
  138. package/vendor/ts-morph/packages/scripts/utils/Memoize.ts +36 -0
  139. package/vendor/ts-morph/packages/scripts/utils/forEachTypeText.ts +23 -0
  140. package/vendor/ts-morph/packages/scripts/utils/makeConstructorsPrivate.ts +26 -0
  141. package/vendor/ts-morph/packages/scripts/utils/mod.ts +4 -0
  142. package/vendor/ts-morph/packages/scripts/utils/printDiagnostics.ts +10 -0
  143. package/vendor/ts-morph/packages/ts-morph/LICENSE +21 -0
  144. package/vendor/ts-morph/packages/ts-morph/lib/ts-morph.d.ts +11198 -0
  145. package/vendor/ts-morph/packages/ts-morph/package.json +78 -0
  146. package/vendor/ts-morph/packages/ts-morph/readme.md +111 -0
  147. package/vendor/ts-morph/readme.md +14 -0
  148. package/vendor/ts-morph/rfcs/README.md +13 -0
  149. package/vendor/ts-morph/rfcs/RFC-0001 - Inserting Into Statements Handling Comments.md +181 -0
  150. package/vendor/ts-morph/tsconfig.common.json +17 -0
  151. package/vitest.config.ts +16 -0
@@ -0,0 +1,504 @@
1
+ /**
2
+ * Scoring Engine - Pure Core
3
+ *
4
+ * Aggregates classification and quality scores into metrics at
5
+ * file, directory, and project levels. This is a PURE module -
6
+ * all functions take data in and return data out with no I/O.
7
+ *
8
+ * Metrics:
9
+ * - Purity: percentage of pure functions
10
+ * - Impurity Quality: average quality score of impure functions
11
+ * - Health: percentage of functions with status 'ok'
12
+ */
13
+
14
+ import type {
15
+ ClassifiedFunction,
16
+ DirectoryScore,
17
+ FileScore,
18
+ ProjectScore,
19
+ RefactoringCandidate,
20
+ StatusBreakdown,
21
+ } from '../types.js'
22
+
23
+ /**
24
+ * Score a single file based on its classified functions
25
+ *
26
+ * @param filePath - Path to the file
27
+ * @param functions - Classified functions from the file
28
+ * @param isTypeOnly - Whether the file contains only types
29
+ * @returns FileScore with all computed metrics
30
+ */
31
+ export function scoreFile(
32
+ filePath: string,
33
+ functions: ClassifiedFunction[],
34
+ isTypeOnly: boolean = false,
35
+ ): FileScore {
36
+ // Handle type-only files
37
+ if (isTypeOnly) {
38
+ return {
39
+ filePath,
40
+ purity: 100,
41
+ impurityQuality: null,
42
+ health: 100,
43
+ pureCount: 0,
44
+ impureCount: 0,
45
+ excludedCount: 0,
46
+ statusBreakdown: { ok: 0, review: 0, refactor: 0 },
47
+ pureLineCount: 0,
48
+ impureLineCount: 0,
49
+ functions: [],
50
+ refactoringCandidates: [],
51
+ typeOnly: true,
52
+ }
53
+ }
54
+
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
62
+
63
+ // Handle empty files
64
+ if (total === 0) {
65
+ return {
66
+ filePath,
67
+ purity: 100,
68
+ impurityQuality: null,
69
+ health: 100,
70
+ pureCount: 0,
71
+ impureCount: 0,
72
+ excludedCount: 0,
73
+ statusBreakdown: { ok: 0, review: 0, refactor: 0 },
74
+ pureLineCount: 0,
75
+ impureLineCount: 0,
76
+ functions: [],
77
+ refactoringCandidates: [],
78
+ allExcluded: true,
79
+ }
80
+ }
81
+
82
+ // Calculate purity (percentage of pure functions)
83
+ const purity = (pureCount / total) * 100
84
+
85
+ // Calculate impurity quality (average quality of impure functions)
86
+ let impurityQuality: number | null = null
87
+ if (impureCount > 0) {
88
+ const totalQuality = impureFunctions.reduce(
89
+ (sum, f) => sum + (f.qualityScore ?? 0),
90
+ 0,
91
+ )
92
+ impurityQuality = totalQuality / impureCount
93
+ }
94
+
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
+ )
114
+
115
+ // Get refactoring candidates (functions with status 'refactor')
116
+ // 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
+ }))
126
+ .sort((a, b) => {
127
+ const impactA = a.bodyLineCount * (100 - a.qualityScore)
128
+ const impactB = b.bodyLineCount * (100 - b.qualityScore)
129
+ return impactB - impactA
130
+ })
131
+
132
+ return {
133
+ filePath,
134
+ purity,
135
+ impurityQuality,
136
+ health,
137
+ pureCount,
138
+ impureCount,
139
+ excludedCount: 0,
140
+ statusBreakdown,
141
+ pureLineCount,
142
+ impureLineCount,
143
+ functions,
144
+ refactoringCandidates,
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Score a directory by aggregating its file scores
150
+ *
151
+ * @param dirPath - Path to the directory
152
+ * @param fileScores - Scores for all files in the directory
153
+ * @returns DirectoryScore with weighted averages
154
+ */
155
+ export function scoreDirectory(
156
+ dirPath: string,
157
+ fileScores: FileScore[],
158
+ ): DirectoryScore {
159
+ // Filter out type-only and empty files for aggregation
160
+ const scorableFiles = fileScores.filter(
161
+ f => !f.typeOnly && !f.allExcluded && f.pureCount + f.impureCount > 0,
162
+ )
163
+
164
+ if (scorableFiles.length === 0) {
165
+ return {
166
+ dirPath,
167
+ purity: 100,
168
+ impurityQuality: null,
169
+ health: 100,
170
+ pureCount: 0,
171
+ impureCount: 0,
172
+ excludedCount: fileScores.reduce((sum, f) => sum + f.excludedCount, 0),
173
+ statusBreakdown: { ok: 0, review: 0, refactor: 0 },
174
+ pureLineCount: 0,
175
+ impureLineCount: 0,
176
+ fileScores,
177
+ }
178
+ }
179
+
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
+ )
222
+
223
+ return {
224
+ dirPath,
225
+ purity,
226
+ impurityQuality,
227
+ health,
228
+ pureCount: totalPure,
229
+ impureCount: totalImpure,
230
+ excludedCount: fileScores.reduce((sum, f) => sum + f.excludedCount, 0),
231
+ statusBreakdown,
232
+ pureLineCount,
233
+ impureLineCount,
234
+ fileScores,
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Score an entire project by aggregating directory scores
240
+ *
241
+ * @param directoryScores - Scores for all directories in the project
242
+ * @param options - Additional options for the project score
243
+ * @returns ProjectScore with all metrics
244
+ */
245
+ export function scoreProject(
246
+ directoryScores: DirectoryScore[],
247
+ options: {
248
+ timestamp?: string
249
+ commitHash?: string | null
250
+ subset?: boolean
251
+ filesGlob?: string
252
+ errors?: { filePath: string; error: string }[]
253
+ } = {},
254
+ ): ProjectScore {
255
+ const {
256
+ timestamp = new Date().toISOString(),
257
+ commitHash = null,
258
+ subset = false,
259
+ filesGlob,
260
+ errors = [],
261
+ } = options
262
+
263
+ // Filter directories with actual functions
264
+ const scorableDirs = directoryScores.filter(
265
+ d => d.pureCount + d.impureCount > 0,
266
+ )
267
+
268
+ if (scorableDirs.length === 0) {
269
+ return {
270
+ purity: 100,
271
+ impurityQuality: null,
272
+ health: 100,
273
+ pureCount: 0,
274
+ impureCount: 0,
275
+ excludedCount: directoryScores.reduce(
276
+ (sum, d) => sum + d.excludedCount,
277
+ 0,
278
+ ),
279
+ statusBreakdown: { ok: 0, review: 0, refactor: 0 },
280
+ pureLineCount: 0,
281
+ impureLineCount: 0,
282
+ directoryScores,
283
+ timestamp,
284
+ commitHash,
285
+ refactoringCandidates: [],
286
+ allExcluded: true,
287
+ ...(subset && { subset: true, filesGlob }),
288
+ ...(errors.length > 0 && { errors }),
289
+ }
290
+ }
291
+
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
+ )
334
+
335
+ // Collect all refactoring candidates across all files
336
+ const allCandidates: RefactoringCandidate[] = []
337
+ for (const dir of directoryScores) {
338
+ for (const file of dir.fileScores) {
339
+ for (const candidate of file.refactoringCandidates) {
340
+ allCandidates.push({
341
+ ...candidate,
342
+ filePath: file.filePath,
343
+ })
344
+ }
345
+ }
346
+ }
347
+
348
+ // Sort by impact: bodyLineCount × (100 - qualityScore) descending
349
+ const refactoringCandidates = allCandidates.sort((a, b) => {
350
+ const impactA = a.bodyLineCount * (100 - a.qualityScore)
351
+ const impactB = b.bodyLineCount * (100 - b.qualityScore)
352
+ return impactB - impactA
353
+ })
354
+
355
+ const result: ProjectScore = {
356
+ purity,
357
+ impurityQuality,
358
+ health,
359
+ pureCount: totalPure,
360
+ impureCount: totalImpure,
361
+ excludedCount: directoryScores.reduce((sum, d) => sum + d.excludedCount, 0),
362
+ statusBreakdown,
363
+ pureLineCount,
364
+ impureLineCount,
365
+ directoryScores,
366
+ timestamp,
367
+ commitHash,
368
+ refactoringCandidates,
369
+ }
370
+
371
+ if (subset && filesGlob !== undefined) {
372
+ result.subset = true
373
+ result.filesGlob = filesGlob
374
+ }
375
+
376
+ if (errors.length > 0) {
377
+ result.errors = errors
378
+ }
379
+
380
+ return result
381
+ }
382
+
383
+ /**
384
+ * Group file scores by directory
385
+ *
386
+ * @param fileScores - Array of file scores
387
+ * @returns Map of directory path to file scores
388
+ */
389
+ export function groupFilesByDirectory(
390
+ fileScores: FileScore[],
391
+ ): Map<string, FileScore[]> {
392
+ const groups = new Map<string, FileScore[]>()
393
+
394
+ for (const fileScore of fileScores) {
395
+ const dirPath = getDirectoryPath(fileScore.filePath)
396
+ const existing = groups.get(dirPath)
397
+ if (existing) {
398
+ existing.push(fileScore)
399
+ } else {
400
+ groups.set(dirPath, [fileScore])
401
+ }
402
+ }
403
+
404
+ return groups
405
+ }
406
+
407
+ /**
408
+ * Get directory path from file path
409
+ */
410
+ function getDirectoryPath(filePath: string): string {
411
+ const lastSlash = filePath.lastIndexOf('/')
412
+ if (lastSlash === -1) return '.'
413
+ return filePath.slice(0, lastSlash)
414
+ }
415
+
416
+ /**
417
+ * Calculate delta between two project scores
418
+ *
419
+ * @param current - Current project score
420
+ * @param previous - Previous project score
421
+ * @returns Delta metrics
422
+ */
423
+ export function calculateDelta(
424
+ current: ProjectScore,
425
+ previous: ProjectScore,
426
+ ): {
427
+ purityDelta: number
428
+ impurityQualityDelta: number | null
429
+ healthDelta: number
430
+ pureCountDelta: number
431
+ impureCountDelta: number
432
+ } {
433
+ return {
434
+ purityDelta: current.purity - previous.purity,
435
+ impurityQualityDelta:
436
+ current.impurityQuality !== null && previous.impurityQuality !== null
437
+ ? current.impurityQuality - previous.impurityQuality
438
+ : null,
439
+ healthDelta: current.health - previous.health,
440
+ pureCountDelta: current.pureCount - previous.pureCount,
441
+ impureCountDelta: current.impureCount - previous.impureCount,
442
+ }
443
+ }
444
+
445
+ /**
446
+ * Get diagnostic insights based on metrics
447
+ *
448
+ * @param score - Project or file score
449
+ * @returns Array of insight messages
450
+ */
451
+ export function getDiagnosticInsights(
452
+ score: Pick<ProjectScore, 'purity' | 'impurityQuality' | 'health'>,
453
+ ): string[] {
454
+ const insights: string[] = []
455
+
456
+ // High purity + low impurity quality
457
+ if (
458
+ score.purity >= 60 &&
459
+ score.impurityQuality !== null &&
460
+ score.impurityQuality < 50
461
+ ) {
462
+ insights.push(
463
+ 'Most code is pure, but the impure code is tangled. Focus on improving the structure of impure functions.',
464
+ )
465
+ }
466
+
467
+ // Low purity + high impurity quality
468
+ if (
469
+ score.purity < 40 &&
470
+ score.impurityQuality !== null &&
471
+ score.impurityQuality >= 70
472
+ ) {
473
+ insights.push(
474
+ "Lots of I/O code, but it's well-structured. Consider extracting more pure business logic.",
475
+ )
476
+ }
477
+
478
+ // Low purity + low impurity quality
479
+ if (
480
+ score.purity < 40 &&
481
+ score.impurityQuality !== null &&
482
+ score.impurityQuality < 50
483
+ ) {
484
+ insights.push(
485
+ 'Significant technical debt in I/O code. Both purity and structure need improvement.',
486
+ )
487
+ }
488
+
489
+ // High health with low purity
490
+ if (score.health >= 80 && score.purity < 50) {
491
+ insights.push(
492
+ 'Despite low purity, impure functions are well-structured. Good FCIS patterns in shell code.',
493
+ )
494
+ }
495
+
496
+ // Low health
497
+ if (score.health < 50) {
498
+ insights.push(
499
+ 'More than half of functions need attention. Prioritize refactoring candidates.',
500
+ )
501
+ }
502
+
503
+ return insights
504
+ }