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.
@@ -13,8 +13,12 @@ import {
13
13
  scoreDirectory,
14
14
  scoreProject,
15
15
  groupFilesByDirectory,
16
+ groupFunctionsByParent,
16
17
  calculateDelta,
17
18
  getDiagnosticInsights,
19
+ getPathAtDepth,
20
+ rollupDirectoriesByDepth,
21
+ aggregateMetrics,
18
22
  } from '../src/scoring/scorer.js'
19
23
  import type {
20
24
  ClassifiedFunction,
@@ -48,6 +52,8 @@ function createClassifiedFunction(
48
52
  classification: 'pure',
49
53
  qualityScore: null,
50
54
  status: 'ok',
55
+ enclosingFunctionStartLine: null,
56
+ isInlineCallback: false,
51
57
  ...overrides,
52
58
  }
53
59
  }
@@ -766,3 +772,883 @@ describe('getDiagnosticInsights', () => {
766
772
  expect(Array.isArray(insights)).toBe(true)
767
773
  })
768
774
  })
775
+
776
+ describe('getPathAtDepth', () => {
777
+ it('should return first segment for depth 0', () => {
778
+ expect(getPathAtDepth('src/services/auth/utils', 0)).toBe('src')
779
+ })
780
+
781
+ it('should return first N+1 segments for depth N', () => {
782
+ expect(getPathAtDepth('src/services/auth/utils', 1)).toBe('src/services')
783
+ expect(getPathAtDepth('src/services/auth/utils', 2)).toBe(
784
+ 'src/services/auth',
785
+ )
786
+ expect(getPathAtDepth('src/services/auth/utils', 3)).toBe(
787
+ 'src/services/auth/utils',
788
+ )
789
+ })
790
+
791
+ it('should return full path when depth exceeds segments', () => {
792
+ expect(getPathAtDepth('src/utils', 5)).toBe('src/utils')
793
+ expect(getPathAtDepth('src', 10)).toBe('src')
794
+ })
795
+
796
+ it('should handle single segment paths', () => {
797
+ expect(getPathAtDepth('src', 0)).toBe('src')
798
+ expect(getPathAtDepth('src', 1)).toBe('src')
799
+ })
800
+
801
+ it('should handle empty path', () => {
802
+ expect(getPathAtDepth('', 0)).toBe('.')
803
+ })
804
+
805
+ it('should normalize backslashes to forward slashes', () => {
806
+ expect(getPathAtDepth('src\\services\\auth', 1)).toBe('src/services')
807
+ })
808
+ })
809
+
810
+ describe('rollupDirectoriesByDepth', () => {
811
+ /**
812
+ * Helper to create a directory score for rollup testing
813
+ */
814
+ function createDirScoreForRollup(
815
+ dirPath: string,
816
+ overrides: Partial<DirectoryScore> = {},
817
+ ): DirectoryScore {
818
+ return {
819
+ dirPath,
820
+ purity: 50,
821
+ impurityQuality: 60,
822
+ health: 75,
823
+ pureCount: 5,
824
+ impureCount: 5,
825
+ excludedCount: 0,
826
+ statusBreakdown: { ok: 7, review: 2, refactor: 1 },
827
+ pureLineCount: 50,
828
+ impureLineCount: 100,
829
+ fileScores: [],
830
+ ...overrides,
831
+ }
832
+ }
833
+
834
+ it('should return empty array for empty input', () => {
835
+ const rolled = rollupDirectoriesByDepth([], 1)
836
+ expect(rolled).toHaveLength(0)
837
+ })
838
+
839
+ it('should aggregate directories at depth 0', () => {
840
+ const dirs = [
841
+ createDirScoreForRollup('src/services/auth', {
842
+ pureCount: 5,
843
+ impureCount: 5,
844
+ }),
845
+ createDirScoreForRollup('src/services/users', {
846
+ pureCount: 3,
847
+ impureCount: 7,
848
+ }),
849
+ createDirScoreForRollup('src/utils/format', {
850
+ pureCount: 8,
851
+ impureCount: 2,
852
+ }),
853
+ ]
854
+
855
+ const rolled = rollupDirectoriesByDepth(dirs, 0)
856
+
857
+ expect(rolled).toHaveLength(1)
858
+ expect(rolled[0]?.dirPath).toBe('src')
859
+ expect(rolled[0]?.pureCount).toBe(16)
860
+ expect(rolled[0]?.impureCount).toBe(14)
861
+ })
862
+
863
+ it('should aggregate directories at depth 1', () => {
864
+ const dirs = [
865
+ createDirScoreForRollup('src/services/auth', {
866
+ pureCount: 5,
867
+ impureCount: 5,
868
+ }),
869
+ createDirScoreForRollup('src/services/users', {
870
+ pureCount: 3,
871
+ impureCount: 7,
872
+ }),
873
+ createDirScoreForRollup('src/utils/format', {
874
+ pureCount: 8,
875
+ impureCount: 2,
876
+ }),
877
+ ]
878
+
879
+ const rolled = rollupDirectoriesByDepth(dirs, 1)
880
+
881
+ expect(rolled).toHaveLength(2)
882
+ expect(rolled.find(d => d.dirPath === 'src/services')?.pureCount).toBe(8)
883
+ expect(rolled.find(d => d.dirPath === 'src/services')?.impureCount).toBe(12)
884
+ expect(rolled.find(d => d.dirPath === 'src/utils')?.pureCount).toBe(8)
885
+ expect(rolled.find(d => d.dirPath === 'src/utils')?.impureCount).toBe(2)
886
+ })
887
+
888
+ it('should calculate weighted health correctly', () => {
889
+ const dirs = [
890
+ createDirScoreForRollup('src/a', {
891
+ pureCount: 10,
892
+ impureCount: 0,
893
+ statusBreakdown: { ok: 10, review: 0, refactor: 0 },
894
+ }),
895
+ createDirScoreForRollup('src/b', {
896
+ pureCount: 0,
897
+ impureCount: 10,
898
+ statusBreakdown: { ok: 0, review: 0, refactor: 10 },
899
+ }),
900
+ ]
901
+
902
+ const rolled = rollupDirectoriesByDepth(dirs, 0)
903
+
904
+ // Weighted by function count: (10 ok out of 20 total) = 50%
905
+ expect(rolled[0]?.health).toBe(50)
906
+ })
907
+
908
+ it('should calculate weighted purity correctly', () => {
909
+ const dirs = [
910
+ createDirScoreForRollup('src/a', {
911
+ pureCount: 8,
912
+ impureCount: 2,
913
+ statusBreakdown: { ok: 10, review: 0, refactor: 0 },
914
+ }),
915
+ createDirScoreForRollup('src/b', {
916
+ pureCount: 2,
917
+ impureCount: 8,
918
+ statusBreakdown: { ok: 10, review: 0, refactor: 0 },
919
+ }),
920
+ ]
921
+
922
+ const rolled = rollupDirectoriesByDepth(dirs, 0)
923
+
924
+ // Total: 10 pure, 10 impure = 50% purity
925
+ expect(rolled[0]?.purity).toBe(50)
926
+ })
927
+
928
+ it('should calculate weighted impurity quality correctly', () => {
929
+ const dirs = [
930
+ createDirScoreForRollup('src/a', {
931
+ pureCount: 0,
932
+ impureCount: 2,
933
+ impurityQuality: 80,
934
+ statusBreakdown: { ok: 2, review: 0, refactor: 0 },
935
+ }),
936
+ createDirScoreForRollup('src/b', {
937
+ pureCount: 0,
938
+ impureCount: 8,
939
+ impurityQuality: 40,
940
+ statusBreakdown: { ok: 8, review: 0, refactor: 0 },
941
+ }),
942
+ ]
943
+
944
+ const rolled = rollupDirectoriesByDepth(dirs, 0)
945
+
946
+ // Weighted: (80*2 + 40*8) / 10 = 48
947
+ expect(rolled[0]?.impurityQuality).toBe(48)
948
+ })
949
+
950
+ it('should handle depth greater than actual nesting', () => {
951
+ const dirs = [
952
+ createDirScoreForRollup('src', { pureCount: 5, impureCount: 5 }),
953
+ ]
954
+
955
+ const rolled = rollupDirectoriesByDepth(dirs, 5)
956
+
957
+ expect(rolled).toHaveLength(1)
958
+ expect(rolled[0]?.dirPath).toBe('src')
959
+ })
960
+
961
+ it('should sort results alphabetically by path', () => {
962
+ const dirs = [
963
+ createDirScoreForRollup('z/deep', {
964
+ pureCount: 1,
965
+ impureCount: 1,
966
+ }),
967
+ createDirScoreForRollup('a/deep', {
968
+ pureCount: 1,
969
+ impureCount: 1,
970
+ }),
971
+ createDirScoreForRollup('m/deep', {
972
+ pureCount: 1,
973
+ impureCount: 1,
974
+ }),
975
+ ]
976
+
977
+ const rolled = rollupDirectoriesByDepth(dirs, 0)
978
+
979
+ expect(rolled.map(d => d.dirPath)).toEqual(['a', 'm', 'z'])
980
+ })
981
+
982
+ it('should have empty fileScores for rolled-up directories', () => {
983
+ const dirs = [
984
+ createDirScoreForRollup('src/a', {
985
+ pureCount: 5,
986
+ impureCount: 5,
987
+ }),
988
+ createDirScoreForRollup('src/b', {
989
+ pureCount: 5,
990
+ impureCount: 5,
991
+ }),
992
+ ]
993
+
994
+ const rolled = rollupDirectoriesByDepth(dirs, 0)
995
+
996
+ expect(rolled[0]?.fileScores).toEqual([])
997
+ })
998
+
999
+ it('should aggregate excludedCount from all directories', () => {
1000
+ const dirs = [
1001
+ createDirScoreForRollup('src/a', {
1002
+ pureCount: 5,
1003
+ impureCount: 5,
1004
+ excludedCount: 3,
1005
+ }),
1006
+ createDirScoreForRollup('src/b', {
1007
+ pureCount: 5,
1008
+ impureCount: 5,
1009
+ excludedCount: 7,
1010
+ }),
1011
+ ]
1012
+
1013
+ const rolled = rollupDirectoriesByDepth(dirs, 0)
1014
+
1015
+ expect(rolled[0]?.excludedCount).toBe(10)
1016
+ })
1017
+
1018
+ it('should handle directories with no functions', () => {
1019
+ const dirs = [
1020
+ createDirScoreForRollup('src/types', {
1021
+ pureCount: 0,
1022
+ impureCount: 0,
1023
+ statusBreakdown: { ok: 0, review: 0, refactor: 0 },
1024
+ }),
1025
+ ]
1026
+
1027
+ const rolled = rollupDirectoriesByDepth(dirs, 0)
1028
+
1029
+ expect(rolled).toHaveLength(1)
1030
+ expect(rolled[0]?.purity).toBe(100)
1031
+ expect(rolled[0]?.health).toBe(100)
1032
+ expect(rolled[0]?.impurityQuality).toBeNull()
1033
+ })
1034
+ })
1035
+
1036
+ describe('aggregateMetrics', () => {
1037
+ it('should aggregate metrics from multiple items', () => {
1038
+ const items = [
1039
+ {
1040
+ pureCount: 5,
1041
+ impureCount: 5,
1042
+ impurityQuality: 80,
1043
+ statusBreakdown: { ok: 8, review: 1, refactor: 1 },
1044
+ pureLineCount: 50,
1045
+ impureLineCount: 100,
1046
+ excludedCount: 2,
1047
+ },
1048
+ {
1049
+ pureCount: 3,
1050
+ impureCount: 7,
1051
+ impurityQuality: 40,
1052
+ statusBreakdown: { ok: 5, review: 3, refactor: 2 },
1053
+ pureLineCount: 30,
1054
+ impureLineCount: 140,
1055
+ excludedCount: 1,
1056
+ },
1057
+ ]
1058
+
1059
+ const aggregated = aggregateMetrics(items)
1060
+
1061
+ expect(aggregated.pureCount).toBe(8)
1062
+ expect(aggregated.impureCount).toBe(12)
1063
+ expect(aggregated.purity).toBe(40) // 8 / 20 = 40%
1064
+ expect(aggregated.statusBreakdown.ok).toBe(13)
1065
+ expect(aggregated.statusBreakdown.review).toBe(4)
1066
+ expect(aggregated.statusBreakdown.refactor).toBe(3)
1067
+ expect(aggregated.health).toBe(65) // 13 / 20 = 65%
1068
+ expect(aggregated.pureLineCount).toBe(80)
1069
+ expect(aggregated.impureLineCount).toBe(240)
1070
+ expect(aggregated.excludedCount).toBe(3)
1071
+ })
1072
+
1073
+ it('should return defaults for empty array', () => {
1074
+ const aggregated = aggregateMetrics([])
1075
+
1076
+ expect(aggregated.purity).toBe(100)
1077
+ expect(aggregated.health).toBe(100)
1078
+ expect(aggregated.impurityQuality).toBeNull()
1079
+ expect(aggregated.pureCount).toBe(0)
1080
+ expect(aggregated.impureCount).toBe(0)
1081
+ })
1082
+
1083
+ it('should calculate weighted impurity quality', () => {
1084
+ const items = [
1085
+ {
1086
+ pureCount: 0,
1087
+ impureCount: 2,
1088
+ impurityQuality: 100,
1089
+ statusBreakdown: { ok: 2, review: 0, refactor: 0 },
1090
+ pureLineCount: 0,
1091
+ impureLineCount: 20,
1092
+ excludedCount: 0,
1093
+ },
1094
+ {
1095
+ pureCount: 0,
1096
+ impureCount: 8,
1097
+ impurityQuality: 50,
1098
+ statusBreakdown: { ok: 8, review: 0, refactor: 0 },
1099
+ pureLineCount: 0,
1100
+ impureLineCount: 80,
1101
+ excludedCount: 0,
1102
+ },
1103
+ ]
1104
+
1105
+ const aggregated = aggregateMetrics(items)
1106
+
1107
+ // Weighted: (100*2 + 50*8) / 10 = 60
1108
+ expect(aggregated.impurityQuality).toBe(60)
1109
+ })
1110
+ })
1111
+
1112
+ describe('groupFunctionsByParent', () => {
1113
+ it('should group inline callbacks under their parent function', () => {
1114
+ const parent = createClassifiedFunction({
1115
+ name: 'parentFunction',
1116
+ startLine: 1,
1117
+ endLine: 50,
1118
+ bodyLineCount: 50,
1119
+ isInlineCallback: false,
1120
+ enclosingFunctionStartLine: null,
1121
+ })
1122
+
1123
+ const callback1 = createClassifiedFunction({
1124
+ name: 'forEach',
1125
+ startLine: 10,
1126
+ endLine: 20,
1127
+ bodyLineCount: 11,
1128
+ parentContext: 'forEach',
1129
+ isInlineCallback: true,
1130
+ enclosingFunctionStartLine: 1, // links to parent
1131
+ })
1132
+
1133
+ const callback2 = createClassifiedFunction({
1134
+ name: 'map',
1135
+ startLine: 25,
1136
+ endLine: 30,
1137
+ bodyLineCount: 6,
1138
+ parentContext: 'map',
1139
+ isInlineCallback: true,
1140
+ enclosingFunctionStartLine: 1, // links to parent
1141
+ })
1142
+
1143
+ const grouped = groupFunctionsByParent([parent, callback1, callback2])
1144
+
1145
+ expect(grouped).toHaveLength(1) // only 1 top-level function
1146
+ expect(grouped[0]?.fn.name).toBe('parentFunction')
1147
+ expect(grouped[0]?.children).toHaveLength(2)
1148
+ })
1149
+
1150
+ it('should treat module-scope inline callbacks as top-level', () => {
1151
+ const moduleScopeCallback = createClassifiedFunction({
1152
+ name: 'map',
1153
+ startLine: 1,
1154
+ endLine: 10,
1155
+ bodyLineCount: 10,
1156
+ parentContext: 'map',
1157
+ isInlineCallback: true,
1158
+ enclosingFunctionStartLine: null, // module scope
1159
+ })
1160
+
1161
+ const grouped = groupFunctionsByParent([moduleScopeCallback])
1162
+
1163
+ expect(grouped).toHaveLength(1)
1164
+ expect(grouped[0]?.fn.name).toBe('map')
1165
+ expect(grouped[0]?.children).toHaveLength(0)
1166
+ })
1167
+
1168
+ it('should treat non-inline-callback functions as top-level', () => {
1169
+ const regularFn = createClassifiedFunction({
1170
+ name: 'regularFunction',
1171
+ isInlineCallback: false,
1172
+ enclosingFunctionStartLine: null,
1173
+ })
1174
+
1175
+ const trpcHandler = createClassifiedFunction({
1176
+ name: 'query',
1177
+ parentContext: 'query',
1178
+ isInlineCallback: false, // tRPC handlers are not inline callbacks
1179
+ enclosingFunctionStartLine: null,
1180
+ })
1181
+
1182
+ const grouped = groupFunctionsByParent([regularFn, trpcHandler])
1183
+
1184
+ expect(grouped).toHaveLength(2)
1185
+ })
1186
+ })
1187
+
1188
+ describe('compositional scoring', () => {
1189
+ it('should count only top-level functions', () => {
1190
+ const parent = createClassifiedFunction({
1191
+ name: 'processItems',
1192
+ startLine: 1,
1193
+ endLine: 50,
1194
+ bodyLineCount: 50,
1195
+ classification: 'pure',
1196
+ isInlineCallback: false,
1197
+ enclosingFunctionStartLine: null,
1198
+ })
1199
+
1200
+ const callback1 = createClassifiedFunction({
1201
+ name: 'map',
1202
+ startLine: 10,
1203
+ endLine: 20,
1204
+ bodyLineCount: 11,
1205
+ classification: 'pure',
1206
+ parentContext: 'map',
1207
+ isInlineCallback: true,
1208
+ enclosingFunctionStartLine: 1,
1209
+ })
1210
+
1211
+ const callback2 = createClassifiedFunction({
1212
+ name: 'filter',
1213
+ startLine: 25,
1214
+ endLine: 30,
1215
+ bodyLineCount: 6,
1216
+ classification: 'pure',
1217
+ parentContext: 'filter',
1218
+ isInlineCallback: true,
1219
+ enclosingFunctionStartLine: 1,
1220
+ })
1221
+
1222
+ const score = scoreFile('/test.ts', [parent, callback1, callback2])
1223
+
1224
+ // Should count only 1 top-level function, not 3
1225
+ expect(score.pureCount + score.impureCount).toBe(1)
1226
+ expect(score.pureCount).toBe(1)
1227
+ expect(score.impureCount).toBe(0)
1228
+ })
1229
+
1230
+ it('should not double-count line counts', () => {
1231
+ const parent = createClassifiedFunction({
1232
+ name: 'processItems',
1233
+ startLine: 1,
1234
+ endLine: 50,
1235
+ bodyLineCount: 50, // includes callback lines
1236
+ classification: 'pure',
1237
+ isInlineCallback: false,
1238
+ enclosingFunctionStartLine: null,
1239
+ })
1240
+
1241
+ const callback = createClassifiedFunction({
1242
+ name: 'forEach',
1243
+ startLine: 10,
1244
+ endLine: 20,
1245
+ bodyLineCount: 11,
1246
+ classification: 'pure',
1247
+ parentContext: 'forEach',
1248
+ isInlineCallback: true,
1249
+ enclosingFunctionStartLine: 1,
1250
+ })
1251
+
1252
+ const score = scoreFile('/test.ts', [parent, callback])
1253
+
1254
+ // Line count should be 50 (parent's lines), not 61 (50 + 11)
1255
+ expect(score.pureLineCount).toBe(50)
1256
+ expect(score.impureLineCount).toBe(0)
1257
+ })
1258
+
1259
+ it('should treat module-scope inline callbacks independently', () => {
1260
+ const moduleScopeCallback = createClassifiedFunction({
1261
+ name: 'map',
1262
+ startLine: 1,
1263
+ endLine: 15,
1264
+ bodyLineCount: 15,
1265
+ statementCount: 5,
1266
+ classification: 'impure',
1267
+ qualityScore: 50,
1268
+ status: 'review',
1269
+ markers: [{ type: 'console-log', detail: 'console.log' }],
1270
+ parentContext: 'map',
1271
+ isInlineCallback: true,
1272
+ enclosingFunctionStartLine: null, // module scope - no enclosing function
1273
+ })
1274
+
1275
+ const score = scoreFile('/test.ts', [moduleScopeCallback])
1276
+
1277
+ // Should count as its own function since it has no parent
1278
+ expect(score.pureCount + score.impureCount).toBe(1)
1279
+ expect(score.impureCount).toBe(1)
1280
+ })
1281
+
1282
+ it('should make parent effectively impure if callback is impure', () => {
1283
+ const parent = createClassifiedFunction({
1284
+ name: 'processItems',
1285
+ startLine: 1,
1286
+ endLine: 50,
1287
+ bodyLineCount: 50,
1288
+ classification: 'pure', // parent itself is pure
1289
+ qualityScore: null,
1290
+ status: 'ok',
1291
+ isInlineCallback: false,
1292
+ enclosingFunctionStartLine: null,
1293
+ })
1294
+
1295
+ const impureCallback = createClassifiedFunction({
1296
+ name: 'forEach',
1297
+ startLine: 10,
1298
+ endLine: 20,
1299
+ bodyLineCount: 11,
1300
+ classification: 'impure', // callback is impure
1301
+ qualityScore: 40,
1302
+ status: 'review',
1303
+ markers: [{ type: 'console-log', detail: 'console.log' }],
1304
+ parentContext: 'forEach',
1305
+ isInlineCallback: true,
1306
+ enclosingFunctionStartLine: 1,
1307
+ })
1308
+
1309
+ const score = scoreFile('/test.ts', [parent, impureCallback])
1310
+
1311
+ // Parent should be counted as effectively impure
1312
+ expect(score.pureCount).toBe(0)
1313
+ expect(score.impureCount).toBe(1)
1314
+ expect(score.impureLineCount).toBe(50)
1315
+ expect(score.pureLineCount).toBe(0)
1316
+ })
1317
+
1318
+ it('should use worst status among parent and children', () => {
1319
+ const parent = createClassifiedFunction({
1320
+ name: 'processItems',
1321
+ startLine: 1,
1322
+ endLine: 50,
1323
+ bodyLineCount: 50,
1324
+ classification: 'impure',
1325
+ qualityScore: 75,
1326
+ status: 'ok', // parent is ok
1327
+ markers: [{ type: 'database-call', detail: 'db.find' }],
1328
+ isInlineCallback: false,
1329
+ enclosingFunctionStartLine: null,
1330
+ })
1331
+
1332
+ const badCallback = createClassifiedFunction({
1333
+ name: 'forEach',
1334
+ startLine: 10,
1335
+ endLine: 20,
1336
+ bodyLineCount: 11,
1337
+ classification: 'impure',
1338
+ qualityScore: 20,
1339
+ status: 'refactor', // callback needs refactoring
1340
+ markers: [{ type: 'console-log', detail: 'console.log' }],
1341
+ parentContext: 'forEach',
1342
+ isInlineCallback: true,
1343
+ enclosingFunctionStartLine: 1,
1344
+ })
1345
+
1346
+ const score = scoreFile('/test.ts', [parent, badCallback])
1347
+
1348
+ // Status breakdown should reflect the worst status
1349
+ expect(score.statusBreakdown.refactor).toBe(1)
1350
+ expect(score.statusBreakdown.ok).toBe(0)
1351
+ expect(score.health).toBe(0) // 0% health because the only function needs refactoring
1352
+ })
1353
+
1354
+ it('should calculate health based on effective status', () => {
1355
+ const goodParent = createClassifiedFunction({
1356
+ name: 'goodFunction',
1357
+ startLine: 1,
1358
+ endLine: 30,
1359
+ bodyLineCount: 30,
1360
+ classification: 'pure',
1361
+ status: 'ok',
1362
+ isInlineCallback: false,
1363
+ enclosingFunctionStartLine: null,
1364
+ })
1365
+
1366
+ const parentWithBadCallback = createClassifiedFunction({
1367
+ name: 'parentWithIssue',
1368
+ startLine: 50,
1369
+ endLine: 100,
1370
+ bodyLineCount: 51,
1371
+ classification: 'impure',
1372
+ qualityScore: 75,
1373
+ status: 'ok',
1374
+ markers: [{ type: 'database-call', detail: 'db.find' }],
1375
+ isInlineCallback: false,
1376
+ enclosingFunctionStartLine: null,
1377
+ })
1378
+
1379
+ const badCallback = createClassifiedFunction({
1380
+ name: 'forEach',
1381
+ startLine: 60,
1382
+ endLine: 70,
1383
+ bodyLineCount: 11,
1384
+ classification: 'impure',
1385
+ qualityScore: 30,
1386
+ status: 'refactor',
1387
+ markers: [{ type: 'console-log', detail: 'console.log' }],
1388
+ parentContext: 'forEach',
1389
+ isInlineCallback: true,
1390
+ enclosingFunctionStartLine: 50,
1391
+ })
1392
+
1393
+ const score = scoreFile('/test.ts', [
1394
+ goodParent,
1395
+ parentWithBadCallback,
1396
+ badCallback,
1397
+ ])
1398
+
1399
+ // 2 top-level functions: 1 ok, 1 refactor
1400
+ expect(score.pureCount + score.impureCount).toBe(2)
1401
+ expect(score.statusBreakdown.ok).toBe(1)
1402
+ expect(score.statusBreakdown.refactor).toBe(1)
1403
+ expect(score.health).toBe(50) // 1/2 = 50%
1404
+ })
1405
+
1406
+ it('should aggregate markers from children into refactoring candidates', () => {
1407
+ const parent = createClassifiedFunction({
1408
+ name: 'processItems',
1409
+ startLine: 1,
1410
+ endLine: 50,
1411
+ bodyLineCount: 50,
1412
+ classification: 'impure',
1413
+ qualityScore: 30,
1414
+ status: 'refactor',
1415
+ markers: [{ type: 'database-call', detail: 'db.find' }],
1416
+ isInlineCallback: false,
1417
+ enclosingFunctionStartLine: null,
1418
+ })
1419
+
1420
+ const callback1 = createClassifiedFunction({
1421
+ name: 'forEach',
1422
+ startLine: 10,
1423
+ endLine: 20,
1424
+ bodyLineCount: 11,
1425
+ classification: 'impure',
1426
+ qualityScore: 20,
1427
+ status: 'refactor',
1428
+ markers: [{ type: 'console-log', detail: 'console.log' }],
1429
+ parentContext: 'forEach',
1430
+ isInlineCallback: true,
1431
+ enclosingFunctionStartLine: 1,
1432
+ })
1433
+
1434
+ const callback2 = createClassifiedFunction({
1435
+ name: 'map',
1436
+ startLine: 25,
1437
+ endLine: 35,
1438
+ bodyLineCount: 11,
1439
+ classification: 'impure',
1440
+ qualityScore: 25,
1441
+ status: 'refactor',
1442
+ markers: [
1443
+ { type: 'logging', detail: 'logger.info' },
1444
+ { type: 'console-log', detail: 'console.log' }, // duplicate type
1445
+ ],
1446
+ parentContext: 'map',
1447
+ isInlineCallback: true,
1448
+ enclosingFunctionStartLine: 1,
1449
+ })
1450
+
1451
+ const score = scoreFile('/test.ts', [parent, callback1, callback2])
1452
+
1453
+ // Should have 1 refactoring candidate (the parent)
1454
+ expect(score.refactoringCandidates).toHaveLength(1)
1455
+
1456
+ const candidate = score.refactoringCandidates[0]
1457
+ expect(candidate?.name).toBe('processItems')
1458
+
1459
+ // Markers should be aggregated and deduped
1460
+ // Parent: database-call
1461
+ // Callback1: console-log
1462
+ // Callback2: logging, console-log (dupe)
1463
+ expect(candidate?.markers).toContain('database-call')
1464
+ expect(candidate?.markers).toContain('console-log')
1465
+ expect(candidate?.markers).toContain('logging')
1466
+ // Should be deduped (console-log appears only once)
1467
+ expect(candidate?.markers.filter(m => m === 'console-log')).toHaveLength(1)
1468
+ })
1469
+
1470
+ it('should blend parent and impure child quality by line count', () => {
1471
+ // Parent: 50 total lines, 40 own lines (after subtracting child), quality 60
1472
+ // Child: 10 lines, quality 40
1473
+ // Composed: (60*40 + 40*10) / 50 = (2400 + 400) / 50 = 56
1474
+ const parent = createClassifiedFunction({
1475
+ name: 'processItems',
1476
+ startLine: 1,
1477
+ endLine: 50,
1478
+ bodyLineCount: 50,
1479
+ classification: 'impure',
1480
+ qualityScore: 60,
1481
+ status: 'review',
1482
+ markers: [{ type: 'database-call', detail: 'db.find' }],
1483
+ isInlineCallback: false,
1484
+ enclosingFunctionStartLine: null,
1485
+ })
1486
+
1487
+ const impureCallback = createClassifiedFunction({
1488
+ name: 'forEach',
1489
+ startLine: 10,
1490
+ endLine: 19,
1491
+ bodyLineCount: 10,
1492
+ classification: 'impure',
1493
+ qualityScore: 40,
1494
+ status: 'review',
1495
+ markers: [{ type: 'console-log', detail: 'console.log' }],
1496
+ parentContext: 'forEach',
1497
+ isInlineCallback: true,
1498
+ enclosingFunctionStartLine: 1,
1499
+ })
1500
+
1501
+ const score = scoreFile('/test.ts', [parent, impureCallback])
1502
+
1503
+ // impurityQuality should be the composed quality: 56
1504
+ expect(score.impurityQuality).toBeCloseTo(56, 0)
1505
+ })
1506
+
1507
+ it('should not affect quality if all children are pure', () => {
1508
+ const parent = createClassifiedFunction({
1509
+ name: 'processItems',
1510
+ startLine: 1,
1511
+ endLine: 50,
1512
+ bodyLineCount: 50,
1513
+ classification: 'impure',
1514
+ qualityScore: 60,
1515
+ status: 'review',
1516
+ markers: [{ type: 'database-call', detail: 'db.find' }],
1517
+ isInlineCallback: false,
1518
+ enclosingFunctionStartLine: null,
1519
+ })
1520
+
1521
+ const pureCallback = createClassifiedFunction({
1522
+ name: 'map',
1523
+ startLine: 10,
1524
+ endLine: 19,
1525
+ bodyLineCount: 10,
1526
+ classification: 'pure',
1527
+ qualityScore: null,
1528
+ status: 'ok',
1529
+ markers: [],
1530
+ parentContext: 'map',
1531
+ isInlineCallback: true,
1532
+ enclosingFunctionStartLine: 1,
1533
+ })
1534
+
1535
+ const score = scoreFile('/test.ts', [parent, pureCallback])
1536
+
1537
+ // Quality should be unchanged (60) since all children are pure
1538
+ expect(score.impurityQuality).toBe(60)
1539
+ })
1540
+
1541
+ it('should weight heavily toward child when child dominates line count', () => {
1542
+ // Parent: 50 total lines, 10 own lines, quality 60
1543
+ // Child: 40 lines, quality 20
1544
+ // Composed: (60*10 + 20*40) / 50 = (600 + 800) / 50 = 28
1545
+ const parent = createClassifiedFunction({
1546
+ name: 'processItems',
1547
+ startLine: 1,
1548
+ endLine: 50,
1549
+ bodyLineCount: 50,
1550
+ classification: 'impure',
1551
+ qualityScore: 60,
1552
+ status: 'review',
1553
+ markers: [{ type: 'database-call', detail: 'db.find' }],
1554
+ isInlineCallback: false,
1555
+ enclosingFunctionStartLine: null,
1556
+ })
1557
+
1558
+ const largeImpureCallback = createClassifiedFunction({
1559
+ name: 'forEach',
1560
+ startLine: 5,
1561
+ endLine: 44,
1562
+ bodyLineCount: 40,
1563
+ classification: 'impure',
1564
+ qualityScore: 20,
1565
+ status: 'refactor',
1566
+ markers: [{ type: 'console-log', detail: 'console.log' }],
1567
+ parentContext: 'forEach',
1568
+ isInlineCallback: true,
1569
+ enclosingFunctionStartLine: 1,
1570
+ })
1571
+
1572
+ const score = scoreFile('/test.ts', [parent, largeImpureCallback])
1573
+
1574
+ // impurityQuality should be heavily influenced by the child: 28
1575
+ expect(score.impurityQuality).toBeCloseTo(28, 0)
1576
+ })
1577
+
1578
+ it('should handle pure parent with impure children', () => {
1579
+ // Parent is pure (no direct markers), but has impure callback
1580
+ // Result should still calculate quality for the impure child
1581
+ const parent = createClassifiedFunction({
1582
+ name: 'processItems',
1583
+ startLine: 1,
1584
+ endLine: 50,
1585
+ bodyLineCount: 50,
1586
+ classification: 'pure', // parent itself is pure
1587
+ qualityScore: null,
1588
+ status: 'ok',
1589
+ markers: [],
1590
+ isInlineCallback: false,
1591
+ enclosingFunctionStartLine: null,
1592
+ })
1593
+
1594
+ const impureCallback = createClassifiedFunction({
1595
+ name: 'forEach',
1596
+ startLine: 10,
1597
+ endLine: 29,
1598
+ bodyLineCount: 20,
1599
+ classification: 'impure',
1600
+ qualityScore: 40,
1601
+ status: 'review',
1602
+ markers: [{ type: 'console-log', detail: 'console.log' }],
1603
+ parentContext: 'forEach',
1604
+ isInlineCallback: true,
1605
+ enclosingFunctionStartLine: 1,
1606
+ })
1607
+
1608
+ const score = scoreFile('/test.ts', [parent, impureCallback])
1609
+
1610
+ // Parent is effectively impure, quality blended:
1611
+ // parentOwnLines = 50 - 20 = 30, parentQuality = 50 (default for pure)
1612
+ // childLines = 20, childQuality = 40
1613
+ // composed = (50*30 + 40*20) / 50 = (1500 + 800) / 50 = 46
1614
+ expect(score.impureCount).toBe(1)
1615
+ expect(score.pureCount).toBe(0)
1616
+ expect(score.impurityQuality).toBeCloseTo(46, 0)
1617
+ })
1618
+
1619
+ it('should not include callbacks in refactoring candidates list', () => {
1620
+ const parent = createClassifiedFunction({
1621
+ name: 'processItems',
1622
+ startLine: 1,
1623
+ endLine: 50,
1624
+ bodyLineCount: 50,
1625
+ classification: 'impure',
1626
+ qualityScore: 75,
1627
+ status: 'ok', // parent is ok
1628
+ markers: [{ type: 'database-call', detail: 'db.find' }],
1629
+ isInlineCallback: false,
1630
+ enclosingFunctionStartLine: null,
1631
+ })
1632
+
1633
+ const badCallback = createClassifiedFunction({
1634
+ name: 'forEach',
1635
+ startLine: 10,
1636
+ endLine: 20,
1637
+ bodyLineCount: 11,
1638
+ classification: 'impure',
1639
+ qualityScore: 20,
1640
+ status: 'refactor', // callback needs refactoring
1641
+ markers: [{ type: 'console-log', detail: 'console.log' }],
1642
+ parentContext: 'forEach',
1643
+ isInlineCallback: true,
1644
+ enclosingFunctionStartLine: 1,
1645
+ })
1646
+
1647
+ const score = scoreFile('/test.ts', [parent, badCallback])
1648
+
1649
+ // The callback itself should NOT appear as a separate refactoring candidate
1650
+ // Only the parent (with effective status 'refactor') should be a candidate
1651
+ expect(score.refactoringCandidates).toHaveLength(1)
1652
+ expect(score.refactoringCandidates[0]?.name).toBe('processItems')
1653
+ })
1654
+ })