fcis 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { SyntaxKind, Project } from 'ts-morph';
2
2
  import * as path from 'path';
3
3
  import * as fs2 from 'fs';
4
+ import * as jsonc from 'jsonc-parser';
4
5
  import chalk from 'chalk';
5
6
 
6
7
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
@@ -15,6 +16,31 @@ var DEFAULT_QUALITY_THRESHOLDS = {
15
16
  okThreshold: 70,
16
17
  reviewThreshold: 40
17
18
  };
19
+ var INLINE_CALLBACK_METHODS = /* @__PURE__ */ new Set([
20
+ // Array methods
21
+ "map",
22
+ "filter",
23
+ "reduce",
24
+ "forEach",
25
+ "find",
26
+ "findIndex",
27
+ "some",
28
+ "every",
29
+ "flatMap",
30
+ "sort",
31
+ "toSorted",
32
+ // Promise methods
33
+ "then",
34
+ "catch",
35
+ "finally",
36
+ // Timing
37
+ "setTimeout",
38
+ "setInterval",
39
+ // Events
40
+ "addEventListener",
41
+ "on",
42
+ "once"
43
+ ]);
18
44
  function extractFunctions(sourceFile) {
19
45
  const functions = [];
20
46
  const filePath = sourceFile.getFilePath();
@@ -102,25 +128,54 @@ function extractFunctions(sourceFile) {
102
128
  if (node.getKind() === SyntaxKind.ArrowFunction) {
103
129
  const arrowFn = node;
104
130
  const parentContext = inferParentContext(arrowFn);
131
+ const isInlineCallback = isInlineCallbackToKnownHOF(parentContext);
132
+ const enclosingFunctionStartLine = isInlineCallback ? findEnclosingFunctionStartLine(arrowFn) : null;
105
133
  addFunction(
106
- extractFunctionData(arrowFn, filePath, "arrow", parentContext, false)
134
+ extractFunctionData(
135
+ arrowFn,
136
+ filePath,
137
+ "arrow",
138
+ parentContext,
139
+ false,
140
+ isInlineCallback,
141
+ enclosingFunctionStartLine
142
+ )
107
143
  );
108
144
  } else if (node.getKind() === SyntaxKind.FunctionExpression) {
109
145
  const funcExpr = node;
110
146
  const parentContext = inferParentContext(funcExpr);
147
+ const isInlineCallback = isInlineCallbackToKnownHOF(parentContext);
148
+ const enclosingFunctionStartLine = isInlineCallback ? findEnclosingFunctionStartLine(funcExpr) : null;
111
149
  addFunction(
112
150
  extractFunctionData(
113
151
  funcExpr,
114
152
  filePath,
115
153
  "function-expression",
116
154
  parentContext,
117
- false
155
+ false,
156
+ isInlineCallback,
157
+ enclosingFunctionStartLine
118
158
  )
119
159
  );
120
160
  }
121
161
  });
122
162
  return functions;
123
163
  }
164
+ function isInlineCallbackToKnownHOF(parentContext) {
165
+ if (parentContext === null) return false;
166
+ return INLINE_CALLBACK_METHODS.has(parentContext);
167
+ }
168
+ function findEnclosingFunctionStartLine(node) {
169
+ let current = node.getParent();
170
+ while (current) {
171
+ const kind = current.getKind();
172
+ if (kind === SyntaxKind.FunctionDeclaration || kind === SyntaxKind.MethodDeclaration || kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.FunctionExpression || kind === SyntaxKind.GetAccessor || kind === SyntaxKind.SetAccessor) {
173
+ return current.getStartLineNumber();
174
+ }
175
+ current = current.getParent();
176
+ }
177
+ return null;
178
+ }
124
179
  function inferParentContext(node) {
125
180
  const parent = node.getParent();
126
181
  if (!parent) return null;
@@ -145,7 +200,7 @@ function inferParentContext(node) {
145
200
  }
146
201
  return null;
147
202
  }
148
- function extractFunctionData(node, filePath, kind, parentContext = null, isExportedOverride) {
203
+ function extractFunctionData(node, filePath, kind, parentContext = null, isExportedOverride, isInlineCallback = false, enclosingFunctionStartLine = null) {
149
204
  const startLine = node.getStartLineNumber();
150
205
  const endLine = node.getEndLineNumber();
151
206
  const bodyLineCount = endLine - startLine + 1;
@@ -181,7 +236,9 @@ function extractFunctionData(node, filePath, kind, parentContext = null, isExpor
181
236
  callSites,
182
237
  hasAwait,
183
238
  propertyAccessChains,
184
- kind
239
+ kind,
240
+ enclosingFunctionStartLine,
241
+ isInlineCallback
185
242
  };
186
243
  }
187
244
  function getBodyNode(node) {
@@ -207,8 +264,12 @@ function isStatement(node) {
207
264
  function checkForConditionals(body) {
208
265
  if (!body) return false;
209
266
  let hasConditionals = false;
210
- body.forEachDescendant((node) => {
267
+ body.forEachDescendant((node, traversal) => {
211
268
  const kind = node.getKind();
269
+ if (kind === SyntaxKind.FunctionDeclaration || kind === SyntaxKind.FunctionExpression || kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.MethodDeclaration) {
270
+ traversal.skip();
271
+ return;
272
+ }
212
273
  if (kind === SyntaxKind.IfStatement || kind === SyntaxKind.ConditionalExpression || kind === SyntaxKind.SwitchStatement) {
213
274
  hasConditionals = true;
214
275
  }
@@ -338,73 +399,30 @@ function isTypeOnlyFile(sourceFile) {
338
399
  }
339
400
  return true;
340
401
  }
341
- function stripJsonComments(jsonString) {
342
- let result = "";
343
- let inString = false;
344
- let inLineComment = false;
345
- let inBlockComment = false;
346
- let i = 0;
347
- while (i < jsonString.length) {
348
- const char = jsonString[i];
349
- const nextChar = jsonString[i + 1];
350
- if (!inLineComment && !inBlockComment && char === '"') {
351
- let backslashCount = 0;
352
- let j = i - 1;
353
- while (j >= 0 && jsonString[j] === "\\") {
354
- backslashCount++;
355
- j--;
356
- }
357
- if (backslashCount % 2 === 0) {
358
- inString = !inString;
359
- }
360
- }
361
- if (inString) {
362
- result += char;
363
- i++;
364
- continue;
365
- }
366
- if (!inBlockComment && char === "/" && nextChar === "/") {
367
- inLineComment = true;
368
- i += 2;
369
- continue;
370
- }
371
- if (inLineComment && (char === "\n" || char === "\r")) {
372
- inLineComment = false;
373
- result += char;
374
- i++;
375
- continue;
376
- }
377
- if (!inLineComment && char === "/" && nextChar === "*") {
378
- inBlockComment = true;
379
- i += 2;
380
- continue;
381
- }
382
- if (inBlockComment && char === "*" && nextChar === "/") {
383
- inBlockComment = false;
384
- i += 2;
385
- continue;
386
- }
387
- if (!inLineComment && !inBlockComment) {
388
- result += char;
389
- }
390
- i++;
402
+ function validateTsconfigContent(content) {
403
+ const errors = [];
404
+ jsonc.parse(content, errors, { allowTrailingComma: true });
405
+ if (errors.length > 0 && errors[0]) {
406
+ return {
407
+ valid: false,
408
+ error: `Parse error at offset ${errors[0].offset}: ${jsonc.printParseErrorCode(errors[0].error)}`
409
+ };
391
410
  }
392
- return result;
411
+ return { valid: true };
393
412
  }
394
413
  function loadProject(options) {
395
414
  const { tsconfigPath, filesGlob } = options;
396
415
  const errors = [];
397
416
  const absoluteTsconfigPath = path.resolve(tsconfigPath);
417
+ const projectDir = path.dirname(absoluteTsconfigPath);
398
418
  if (!fs2.existsSync(absoluteTsconfigPath)) {
399
419
  throw new Error(`tsconfig.json not found at: ${absoluteTsconfigPath}`);
400
420
  }
401
- try {
402
- const content = fs2.readFileSync(absoluteTsconfigPath, "utf-8");
403
- const strippedContent = stripJsonComments(content);
404
- JSON.parse(strippedContent);
405
- } catch (e) {
421
+ const content = fs2.readFileSync(absoluteTsconfigPath, "utf-8");
422
+ const validationResult = validateTsconfigContent(content);
423
+ if (!validationResult.valid) {
406
424
  throw new Error(
407
- `Invalid tsconfig.json at ${absoluteTsconfigPath}: ${e instanceof Error ? e.message : String(e)}`
425
+ `Invalid tsconfig.json at ${absoluteTsconfigPath}: ${validationResult.error}`
408
426
  );
409
427
  }
410
428
  const project = new Project({
@@ -413,7 +431,7 @@ function loadProject(options) {
413
431
  });
414
432
  let sourceFiles = project.getSourceFiles().filter((sf) => {
415
433
  const filePath = sf.getFilePath();
416
- return filePath.endsWith(".ts") && !filePath.endsWith(".d.ts") && !filePath.endsWith(".test.ts") && !filePath.endsWith(".spec.ts") && !filePath.includes("/node_modules/") && !filePath.includes("/generated/");
434
+ return filePath.startsWith(projectDir + "/") && filePath.endsWith(".ts") && !filePath.endsWith(".d.ts") && !filePath.endsWith(".test.ts") && !filePath.endsWith(".spec.ts") && !filePath.includes("/node_modules/") && !filePath.includes("/generated/");
417
435
  });
418
436
  if (filesGlob) {
419
437
  const globPattern = createGlobMatcher(filesGlob);
@@ -1015,6 +1033,122 @@ function getStatusColor(status) {
1015
1033
  }
1016
1034
 
1017
1035
  // src/scoring/scorer.ts
1036
+ function groupFunctionsByParent(functions) {
1037
+ const byStartLine = /* @__PURE__ */ new Map();
1038
+ for (const fn of functions) {
1039
+ byStartLine.set(fn.startLine, fn);
1040
+ }
1041
+ const topLevel = [];
1042
+ const childrenByParentLine = /* @__PURE__ */ new Map();
1043
+ for (const fn of functions) {
1044
+ const isTopLevel = !fn.isInlineCallback || fn.enclosingFunctionStartLine === null;
1045
+ if (isTopLevel) {
1046
+ topLevel.push({ fn, children: [] });
1047
+ } else if (fn.enclosingFunctionStartLine !== null) {
1048
+ const existing = childrenByParentLine.get(fn.enclosingFunctionStartLine);
1049
+ if (existing) {
1050
+ existing.push(fn);
1051
+ } else {
1052
+ childrenByParentLine.set(fn.enclosingFunctionStartLine, [fn]);
1053
+ }
1054
+ }
1055
+ }
1056
+ for (const entry of topLevel) {
1057
+ const children = childrenByParentLine.get(entry.fn.startLine);
1058
+ if (children) {
1059
+ entry.children = children;
1060
+ }
1061
+ }
1062
+ return topLevel;
1063
+ }
1064
+ function isEffectivelyImpure(entry) {
1065
+ if (entry.fn.classification === "impure") return true;
1066
+ return entry.children.some((child) => child.classification === "impure");
1067
+ }
1068
+ function getEffectiveStatus(entry) {
1069
+ const statuses = [entry.fn.status, ...entry.children.map((c) => c.status)];
1070
+ if (statuses.includes("refactor")) return "refactor";
1071
+ if (statuses.includes("review")) return "review";
1072
+ return "ok";
1073
+ }
1074
+ function computeComposedQuality(entry) {
1075
+ const impureChildren = entry.children.filter(
1076
+ (c) => c.classification === "impure"
1077
+ );
1078
+ if (impureChildren.length === 0) {
1079
+ return entry.fn.qualityScore ?? 50;
1080
+ }
1081
+ const childTotalLines = entry.children.reduce(
1082
+ (sum, c) => sum + c.bodyLineCount,
1083
+ 0
1084
+ );
1085
+ const parentOwnLines = Math.max(1, entry.fn.bodyLineCount - childTotalLines);
1086
+ const totalLines = parentOwnLines + childTotalLines;
1087
+ const parentQuality = entry.fn.qualityScore ?? 50;
1088
+ const parentContribution = parentQuality * parentOwnLines;
1089
+ const childContribution = impureChildren.reduce(
1090
+ (sum, c) => sum + (c.qualityScore ?? 50) * c.bodyLineCount,
1091
+ 0
1092
+ );
1093
+ return (parentContribution + childContribution) / totalLines;
1094
+ }
1095
+ function aggregateMetrics(items) {
1096
+ const scorableItems = items.filter(
1097
+ (item) => item.pureCount + item.impureCount > 0
1098
+ );
1099
+ const totalPure = items.reduce((sum, item) => sum + item.pureCount, 0);
1100
+ const totalImpure = items.reduce((sum, item) => sum + item.impureCount, 0);
1101
+ const total = totalPure + totalImpure;
1102
+ if (total === 0) {
1103
+ return {
1104
+ purity: 100,
1105
+ impurityQuality: null,
1106
+ health: 100,
1107
+ pureCount: 0,
1108
+ impureCount: 0,
1109
+ excludedCount: items.reduce((sum, item) => sum + item.excludedCount, 0),
1110
+ statusBreakdown: { ok: 0, review: 0, refactor: 0 },
1111
+ pureLineCount: 0,
1112
+ impureLineCount: 0
1113
+ };
1114
+ }
1115
+ const purity = totalPure / total * 100;
1116
+ let impurityQuality = null;
1117
+ if (totalImpure > 0) {
1118
+ const weightedQuality = scorableItems.reduce((sum, item) => {
1119
+ if (item.impurityQuality !== null && item.impureCount > 0) {
1120
+ return sum + item.impurityQuality * item.impureCount;
1121
+ }
1122
+ return sum;
1123
+ }, 0);
1124
+ impurityQuality = weightedQuality / totalImpure;
1125
+ }
1126
+ const statusBreakdown = {
1127
+ ok: items.reduce((sum, item) => sum + item.statusBreakdown.ok, 0),
1128
+ review: items.reduce((sum, item) => sum + item.statusBreakdown.review, 0),
1129
+ refactor: items.reduce(
1130
+ (sum, item) => sum + item.statusBreakdown.refactor,
1131
+ 0
1132
+ )
1133
+ };
1134
+ const health = statusBreakdown.ok / total * 100;
1135
+ const pureLineCount = items.reduce((sum, item) => sum + item.pureLineCount, 0);
1136
+ const impureLineCount = items.reduce(
1137
+ (sum, item) => sum + item.impureLineCount,
1138
+ 0
1139
+ );
1140
+ return {
1141
+ purity,
1142
+ impurityQuality,
1143
+ health,
1144
+ pureCount: totalPure,
1145
+ impureCount: totalImpure,
1146
+ excludedCount: items.reduce((sum, item) => sum + item.excludedCount, 0),
1147
+ statusBreakdown,
1148
+ pureLineCount,
1149
+ impureLineCount
1150
+ };
1151
+ }
1018
1152
  function scoreFile(filePath, functions, isTypeOnly = false) {
1019
1153
  if (isTypeOnly) {
1020
1154
  return {
@@ -1033,12 +1167,8 @@ function scoreFile(filePath, functions, isTypeOnly = false) {
1033
1167
  typeOnly: true
1034
1168
  };
1035
1169
  }
1036
- const pureFunctions = functions.filter((f) => f.classification === "pure");
1037
- const impureFunctions = functions.filter((f) => f.classification === "impure");
1038
- const pureCount = pureFunctions.length;
1039
- const impureCount = impureFunctions.length;
1040
- const total = pureCount + impureCount;
1041
- if (total === 0) {
1170
+ const grouped = groupFunctionsByParent(functions);
1171
+ if (grouped.length === 0) {
1042
1172
  return {
1043
1173
  filePath,
1044
1174
  purity: 100,
@@ -1055,36 +1185,49 @@ function scoreFile(filePath, functions, isTypeOnly = false) {
1055
1185
  allExcluded: true
1056
1186
  };
1057
1187
  }
1058
- const purity = pureCount / total * 100;
1188
+ let pureCount = 0;
1189
+ let impureCount = 0;
1190
+ let pureLineCount = 0;
1191
+ let impureLineCount = 0;
1192
+ const statusBreakdown = { ok: 0, review: 0, refactor: 0 };
1193
+ for (const entry of grouped) {
1194
+ const effectivelyImpure = isEffectivelyImpure(entry);
1195
+ const effectiveStatus = getEffectiveStatus(entry);
1196
+ if (effectivelyImpure) {
1197
+ impureCount++;
1198
+ impureLineCount += entry.fn.bodyLineCount;
1199
+ } else {
1200
+ pureCount++;
1201
+ pureLineCount += entry.fn.bodyLineCount;
1202
+ }
1203
+ statusBreakdown[effectiveStatus]++;
1204
+ }
1205
+ const total = pureCount + impureCount;
1206
+ const purity = total > 0 ? pureCount / total * 100 : 100;
1059
1207
  let impurityQuality = null;
1060
1208
  if (impureCount > 0) {
1061
- const totalQuality = impureFunctions.reduce(
1062
- (sum, f) => sum + (f.qualityScore ?? 0),
1209
+ const impureEntries = grouped.filter((e) => isEffectivelyImpure(e));
1210
+ const totalQuality = impureEntries.reduce(
1211
+ (sum, e) => sum + computeComposedQuality(e),
1063
1212
  0
1064
1213
  );
1065
1214
  impurityQuality = totalQuality / impureCount;
1066
1215
  }
1067
- const statusBreakdown = {
1068
- ok: functions.filter((f) => f.status === "ok").length,
1069
- review: functions.filter((f) => f.status === "review").length,
1070
- refactor: functions.filter((f) => f.status === "refactor").length
1071
- };
1072
- const health = statusBreakdown.ok / total * 100;
1073
- const pureLineCount = pureFunctions.reduce(
1074
- (sum, f) => sum + f.bodyLineCount,
1075
- 0
1076
- );
1077
- const impureLineCount = impureFunctions.reduce(
1078
- (sum, f) => sum + f.bodyLineCount,
1079
- 0
1080
- );
1081
- const refactoringCandidates = impureFunctions.filter((f) => f.status === "refactor").map((f) => ({
1082
- name: f.name,
1083
- startLine: f.startLine,
1084
- bodyLineCount: f.bodyLineCount,
1085
- qualityScore: f.qualityScore ?? 0,
1086
- markers: f.markers.map((m) => m.type)
1087
- })).sort((a, b) => {
1216
+ const health = total > 0 ? statusBreakdown.ok / total * 100 : 100;
1217
+ const refactoringCandidates = grouped.filter((e) => isEffectivelyImpure(e) && getEffectiveStatus(e) === "refactor").map((e) => {
1218
+ const allMarkers = [
1219
+ ...e.fn.markers.map((m) => m.type),
1220
+ ...e.children.flatMap((c) => c.markers.map((m) => m.type))
1221
+ ];
1222
+ const uniqueMarkers = [...new Set(allMarkers)];
1223
+ return {
1224
+ name: e.fn.name,
1225
+ startLine: e.fn.startLine,
1226
+ bodyLineCount: e.fn.bodyLineCount,
1227
+ qualityScore: e.fn.qualityScore ?? 0,
1228
+ markers: uniqueMarkers
1229
+ };
1230
+ }).sort((a, b) => {
1088
1231
  const impactA = a.bodyLineCount * (100 - a.qualityScore);
1089
1232
  const impactB = b.bodyLineCount * (100 - b.qualityScore);
1090
1233
  return impactB - impactA;
@@ -1101,6 +1244,7 @@ function scoreFile(filePath, functions, isTypeOnly = false) {
1101
1244
  pureLineCount,
1102
1245
  impureLineCount,
1103
1246
  functions,
1247
+ // Keep all functions for drill-down (includes isInlineCallback flag)
1104
1248
  refactoringCandidates
1105
1249
  };
1106
1250
  }
@@ -1109,62 +1253,19 @@ function scoreDirectory(dirPath, fileScores) {
1109
1253
  (f) => !f.typeOnly && !f.allExcluded && f.pureCount + f.impureCount > 0
1110
1254
  );
1111
1255
  if (scorableFiles.length === 0) {
1256
+ const emptyMetrics = aggregateMetrics([]);
1112
1257
  return {
1113
1258
  dirPath,
1114
- purity: 100,
1115
- impurityQuality: null,
1116
- health: 100,
1117
- pureCount: 0,
1118
- impureCount: 0,
1259
+ ...emptyMetrics,
1119
1260
  excludedCount: fileScores.reduce((sum, f) => sum + f.excludedCount, 0),
1120
- statusBreakdown: { ok: 0, review: 0, refactor: 0 },
1121
- pureLineCount: 0,
1122
- impureLineCount: 0,
1123
1261
  fileScores
1124
1262
  };
1125
1263
  }
1126
- const totalPure = scorableFiles.reduce((sum, f) => sum + f.pureCount, 0);
1127
- const totalImpure = scorableFiles.reduce((sum, f) => sum + f.impureCount, 0);
1128
- const total = totalPure + totalImpure;
1129
- const purity = total > 0 ? totalPure / total * 100 : 100;
1130
- let impurityQuality = null;
1131
- if (totalImpure > 0) {
1132
- const weightedQuality = scorableFiles.reduce((sum, f) => {
1133
- if (f.impurityQuality !== null && f.impureCount > 0) {
1134
- return sum + f.impurityQuality * f.impureCount;
1135
- }
1136
- return sum;
1137
- }, 0);
1138
- impurityQuality = weightedQuality / totalImpure;
1139
- }
1140
- const statusBreakdown = {
1141
- ok: scorableFiles.reduce((sum, f) => sum + f.statusBreakdown.ok, 0),
1142
- review: scorableFiles.reduce((sum, f) => sum + f.statusBreakdown.review, 0),
1143
- refactor: scorableFiles.reduce(
1144
- (sum, f) => sum + f.statusBreakdown.refactor,
1145
- 0
1146
- )
1147
- };
1148
- const health = total > 0 ? statusBreakdown.ok / total * 100 : 100;
1149
- const pureLineCount = scorableFiles.reduce(
1150
- (sum, f) => sum + f.pureLineCount,
1151
- 0
1152
- );
1153
- const impureLineCount = scorableFiles.reduce(
1154
- (sum, f) => sum + f.impureLineCount,
1155
- 0
1156
- );
1264
+ const aggregated = aggregateMetrics(scorableFiles);
1157
1265
  return {
1158
1266
  dirPath,
1159
- purity,
1160
- impurityQuality,
1161
- health,
1162
- pureCount: totalPure,
1163
- impureCount: totalImpure,
1267
+ ...aggregated,
1164
1268
  excludedCount: fileScores.reduce((sum, f) => sum + f.excludedCount, 0),
1165
- statusBreakdown,
1166
- pureLineCount,
1167
- impureLineCount,
1168
1269
  fileScores
1169
1270
  };
1170
1271
  }
@@ -1180,19 +1281,13 @@ function scoreProject(directoryScores, options = {}) {
1180
1281
  (d) => d.pureCount + d.impureCount > 0
1181
1282
  );
1182
1283
  if (scorableDirs.length === 0) {
1284
+ const emptyMetrics = aggregateMetrics([]);
1183
1285
  return {
1184
- purity: 100,
1185
- impurityQuality: null,
1186
- health: 100,
1187
- pureCount: 0,
1188
- impureCount: 0,
1286
+ ...emptyMetrics,
1189
1287
  excludedCount: directoryScores.reduce(
1190
1288
  (sum, d) => sum + d.excludedCount,
1191
1289
  0
1192
1290
  ),
1193
- statusBreakdown: { ok: 0, review: 0, refactor: 0 },
1194
- pureLineCount: 0,
1195
- impureLineCount: 0,
1196
1291
  directoryScores,
1197
1292
  timestamp,
1198
1293
  commitHash,
@@ -1202,37 +1297,7 @@ function scoreProject(directoryScores, options = {}) {
1202
1297
  ...errors.length > 0 && { errors }
1203
1298
  };
1204
1299
  }
1205
- const totalPure = scorableDirs.reduce((sum, d) => sum + d.pureCount, 0);
1206
- const totalImpure = scorableDirs.reduce((sum, d) => sum + d.impureCount, 0);
1207
- const total = totalPure + totalImpure;
1208
- const purity = total > 0 ? totalPure / total * 100 : 100;
1209
- let impurityQuality = null;
1210
- if (totalImpure > 0) {
1211
- const weightedQuality = scorableDirs.reduce((sum, d) => {
1212
- if (d.impurityQuality !== null && d.impureCount > 0) {
1213
- return sum + d.impurityQuality * d.impureCount;
1214
- }
1215
- return sum;
1216
- }, 0);
1217
- impurityQuality = weightedQuality / totalImpure;
1218
- }
1219
- const statusBreakdown = {
1220
- ok: scorableDirs.reduce((sum, d) => sum + d.statusBreakdown.ok, 0),
1221
- review: scorableDirs.reduce((sum, d) => sum + d.statusBreakdown.review, 0),
1222
- refactor: scorableDirs.reduce(
1223
- (sum, d) => sum + d.statusBreakdown.refactor,
1224
- 0
1225
- )
1226
- };
1227
- const health = total > 0 ? statusBreakdown.ok / total * 100 : 100;
1228
- const pureLineCount = scorableDirs.reduce(
1229
- (sum, d) => sum + d.pureLineCount,
1230
- 0
1231
- );
1232
- const impureLineCount = scorableDirs.reduce(
1233
- (sum, d) => sum + d.impureLineCount,
1234
- 0
1235
- );
1300
+ const aggregated = aggregateMetrics(scorableDirs);
1236
1301
  const allCandidates = [];
1237
1302
  for (const dir of directoryScores) {
1238
1303
  for (const file of dir.fileScores) {
@@ -1250,15 +1315,8 @@ function scoreProject(directoryScores, options = {}) {
1250
1315
  return impactB - impactA;
1251
1316
  });
1252
1317
  const result = {
1253
- purity,
1254
- impurityQuality,
1255
- health,
1256
- pureCount: totalPure,
1257
- impureCount: totalImpure,
1318
+ ...aggregated,
1258
1319
  excludedCount: directoryScores.reduce((sum, d) => sum + d.excludedCount, 0),
1259
- statusBreakdown,
1260
- pureLineCount,
1261
- impureLineCount,
1262
1320
  directoryScores,
1263
1321
  timestamp,
1264
1322
  commitHash,
@@ -1291,6 +1349,56 @@ function getDirectoryPath(filePath) {
1291
1349
  if (lastSlash === -1) return ".";
1292
1350
  return filePath.slice(0, lastSlash);
1293
1351
  }
1352
+ function getPathAtDepth(relativePath, depth) {
1353
+ const normalized = relativePath.replace(/\\/g, "/");
1354
+ const segments = normalized.split("/").filter(Boolean);
1355
+ if (segments.length === 0) {
1356
+ return ".";
1357
+ }
1358
+ const segmentCount = depth + 1;
1359
+ if (segmentCount >= segments.length) {
1360
+ return segments.join("/");
1361
+ }
1362
+ return segments.slice(0, segmentCount).join("/");
1363
+ }
1364
+ function rollupDirectoriesByDepth(directories, depth) {
1365
+ if (directories.length === 0) {
1366
+ return [];
1367
+ }
1368
+ const groups = /* @__PURE__ */ new Map();
1369
+ for (const dir of directories) {
1370
+ const truncatedPath = getPathAtDepth(dir.dirPath, depth);
1371
+ const existing = groups.get(truncatedPath);
1372
+ if (existing) {
1373
+ existing.push(dir);
1374
+ } else {
1375
+ groups.set(truncatedPath, [dir]);
1376
+ }
1377
+ }
1378
+ const rolledUp = [];
1379
+ for (const [groupPath, groupDirs] of groups) {
1380
+ const scorableDirs = groupDirs.filter((d) => d.pureCount + d.impureCount > 0);
1381
+ if (scorableDirs.length === 0) {
1382
+ const emptyMetrics = aggregateMetrics([]);
1383
+ rolledUp.push({
1384
+ dirPath: groupPath,
1385
+ ...emptyMetrics,
1386
+ excludedCount: groupDirs.reduce((sum, d) => sum + d.excludedCount, 0),
1387
+ fileScores: []
1388
+ });
1389
+ continue;
1390
+ }
1391
+ const aggregated = aggregateMetrics(scorableDirs);
1392
+ rolledUp.push({
1393
+ dirPath: groupPath,
1394
+ ...aggregated,
1395
+ excludedCount: groupDirs.reduce((sum, d) => sum + d.excludedCount, 0),
1396
+ fileScores: []
1397
+ });
1398
+ }
1399
+ rolledUp.sort((a, b) => a.dirPath.localeCompare(b.dirPath));
1400
+ return rolledUp;
1401
+ }
1294
1402
  function calculateDelta(current, previous) {
1295
1403
  return {
1296
1404
  purityDelta: current.purity - previous.purity,
@@ -1478,12 +1586,6 @@ var MARKER_CATALOG = {
1478
1586
  category: "io",
1479
1587
  severity: "high"
1480
1588
  },
1481
- "fs-import": {
1482
- type: "fs-import",
1483
- description: "Import from file system module",
1484
- category: "io",
1485
- severity: "medium"
1486
- },
1487
1589
  "fs-call": {
1488
1590
  type: "fs-call",
1489
1591
  description: "File system operation call",
@@ -1534,8 +1636,24 @@ function getAllMarkerTypes() {
1534
1636
  return Object.keys(MARKER_CATALOG);
1535
1637
  }
1536
1638
  function generateJsonReport(score, options = {}) {
1537
- const { pretty = true, includeFunction = false } = options;
1538
- const reportData = prepareReportData(score, includeFunction);
1639
+ const {
1640
+ pretty = true,
1641
+ includeFunction = true,
1642
+ dirDepth,
1643
+ projectRoot = process.cwd()
1644
+ } = options;
1645
+ let reportData = prepareReportData(score, includeFunction);
1646
+ if (dirDepth !== void 0) {
1647
+ const relativized = score.directoryScores.map((d) => ({
1648
+ ...d,
1649
+ dirPath: path.relative(projectRoot, d.dirPath).replace(/\\/g, "/")
1650
+ }));
1651
+ const rolledUp = rollupDirectoriesByDepth(relativized, dirDepth);
1652
+ reportData = {
1653
+ ...reportData,
1654
+ rolledUpDirectories: rolledUp
1655
+ };
1656
+ }
1539
1657
  if (pretty) {
1540
1658
  return JSON.stringify(reportData, null, 2);
1541
1659
  }
@@ -1619,8 +1737,79 @@ function generateComparisonReport(current, previous) {
1619
1737
  resolvedRefactoringCandidates
1620
1738
  };
1621
1739
  }
1740
+ function getMetricColor(value) {
1741
+ if (value >= 70) return chalk.green;
1742
+ if (value >= 50) return chalk.yellow;
1743
+ return chalk.red;
1744
+ }
1745
+ function getMetricColorNullable(value) {
1746
+ if (value === null) return chalk.gray;
1747
+ return getMetricColor(value);
1748
+ }
1749
+ function toRelativePath(absolutePath) {
1750
+ const cwd = process.cwd();
1751
+ if (absolutePath.startsWith(cwd)) {
1752
+ const relative3 = path.relative(cwd, absolutePath);
1753
+ return relative3 || ".";
1754
+ }
1755
+ return absolutePath;
1756
+ }
1757
+ function relativizeDirectoryPaths(directories, projectRoot) {
1758
+ return directories.map((d) => ({
1759
+ ...d,
1760
+ dirPath: path.relative(projectRoot, d.dirPath).replace(/\\/g, "/")
1761
+ }));
1762
+ }
1763
+ function printDirectoryTable(directories, options) {
1764
+ const { title, includeQualityColumn, sortBy, emptyMessage } = options;
1765
+ let filtered = directories.filter((d) => d.pureCount + d.impureCount > 0);
1766
+ if (sortBy === "health-asc") {
1767
+ filtered = [...filtered].sort((a, b) => a.health - b.health);
1768
+ }
1769
+ console.log(chalk.bold(title));
1770
+ console.log(chalk.gray("\u2500".repeat(80)));
1771
+ if (includeQualityColumn) {
1772
+ console.log(
1773
+ chalk.gray(
1774
+ padEnd("Directory", 40) + padEnd("Health", 10) + padEnd("Purity", 10) + padEnd("Quality", 10) + padEnd("Functions", 10)
1775
+ )
1776
+ );
1777
+ } else {
1778
+ console.log(
1779
+ chalk.gray(
1780
+ padEnd("Directory", 45) + padEnd("Health", 10) + padEnd("Purity", 10) + padEnd("Functions", 15)
1781
+ )
1782
+ );
1783
+ }
1784
+ console.log(chalk.gray("\u2500".repeat(80)));
1785
+ if (filtered.length === 0) {
1786
+ if (emptyMessage) {
1787
+ console.log(chalk.gray(` ${emptyMessage}`));
1788
+ }
1789
+ console.log();
1790
+ return;
1791
+ }
1792
+ for (const dir of filtered) {
1793
+ const healthColor = getMetricColor(dir.health);
1794
+ const purityColor = getMetricColor(dir.purity);
1795
+ const total = dir.pureCount + dir.impureCount;
1796
+ if (includeQualityColumn) {
1797
+ const qualityStr = dir.impurityQuality !== null ? dir.impurityQuality.toFixed(0) + "%" : "\u2014";
1798
+ const qualityColor = getMetricColorNullable(dir.impurityQuality);
1799
+ console.log(
1800
+ padEnd(dir.dirPath, 40) + healthColor(padEnd(dir.health.toFixed(0) + "%", 10)) + purityColor(padEnd(dir.purity.toFixed(0) + "%", 10)) + qualityColor(padEnd(qualityStr, 10)) + padEnd(`${dir.pureCount}/${total}`, 10)
1801
+ );
1802
+ } else {
1803
+ const relativePath = toRelativePath(dir.dirPath);
1804
+ console.log(
1805
+ padEnd(relativePath, 45) + healthColor(padEnd(dir.health.toFixed(0) + "%", 10)) + purityColor(padEnd(dir.purity.toFixed(0) + "%", 10)) + padEnd(`${dir.pureCount}/${total}`, 15)
1806
+ );
1807
+ }
1808
+ }
1809
+ console.log();
1810
+ }
1622
1811
  function printConsoleReport(score, options = {}) {
1623
- const { verbose = false } = options;
1812
+ const { verbose = false, dirDepth } = options;
1624
1813
  printHeader();
1625
1814
  printProjectSummary(score);
1626
1815
  printStatusBreakdown(
@@ -1628,8 +1817,24 @@ function printConsoleReport(score, options = {}) {
1628
1817
  score.pureCount + score.impureCount
1629
1818
  );
1630
1819
  printInsights(score);
1631
- if (verbose) {
1632
- printDirectoryBreakdown(score.directoryScores);
1820
+ if (dirDepth !== void 0) {
1821
+ const relativized = relativizeDirectoryPaths(
1822
+ score.directoryScores,
1823
+ process.cwd()
1824
+ );
1825
+ const rolledUp = rollupDirectoriesByDepth(relativized, dirDepth);
1826
+ printDirectoryTable(rolledUp, {
1827
+ title: `Directory Breakdown (depth=${dirDepth}):`,
1828
+ includeQualityColumn: true,
1829
+ sortBy: "preserve-order",
1830
+ emptyMessage: "No directories with functions found"
1831
+ });
1832
+ } else if (verbose) {
1833
+ printDirectoryTable(score.directoryScores, {
1834
+ title: "Directory Breakdown:",
1835
+ includeQualityColumn: false,
1836
+ sortBy: "health-asc"
1837
+ });
1633
1838
  } else {
1634
1839
  printWorstDirectories(score.directoryScores);
1635
1840
  }
@@ -1649,8 +1854,8 @@ function printHeader() {
1649
1854
  }
1650
1855
  function printProjectSummary(score) {
1651
1856
  const healthBar = createProgressBar(score.health, 25);
1652
- const healthColor = score.health >= 70 ? chalk.green : score.health >= 50 ? chalk.yellow : chalk.red;
1653
- const purityColor = score.purity >= 70 ? chalk.green : score.purity >= 50 ? chalk.yellow : chalk.red;
1857
+ const healthColor = getMetricColor(score.health);
1858
+ const purityColor = getMetricColor(score.purity);
1654
1859
  console.log(
1655
1860
  `Project Health: ${healthColor(score.health.toFixed(0) + "%")} ${healthBar}`
1656
1861
  );
@@ -1658,7 +1863,7 @@ function printProjectSummary(score) {
1658
1863
  ` Purity: ${purityColor(score.purity.toFixed(0) + "%")} (${score.pureCount} pure / ${score.pureCount + score.impureCount} total)`
1659
1864
  );
1660
1865
  if (score.impurityQuality !== null) {
1661
- const qualityColor = score.impurityQuality >= 70 ? chalk.green : score.impurityQuality >= 50 ? chalk.yellow : chalk.red;
1866
+ const qualityColor = getMetricColor(score.impurityQuality);
1662
1867
  console.log(
1663
1868
  ` Impurity Quality: ${qualityColor(score.impurityQuality.toFixed(0) + "%")} average`
1664
1869
  );
@@ -1691,34 +1896,12 @@ function printInsights(score) {
1691
1896
  console.log();
1692
1897
  }
1693
1898
  }
1694
- function printDirectoryBreakdown(directories) {
1695
- const sorted = [...directories].filter((d) => d.pureCount + d.impureCount > 0).sort((a, b) => a.health - b.health);
1696
- if (sorted.length === 0) return;
1697
- console.log(chalk.bold("Directory Breakdown:"));
1698
- console.log(chalk.gray("\u2500".repeat(80)));
1699
- console.log(
1700
- chalk.gray(
1701
- padEnd("Directory", 45) + padEnd("Health", 10) + padEnd("Purity", 10) + padEnd("Functions", 15)
1702
- )
1703
- );
1704
- console.log(chalk.gray("\u2500".repeat(80)));
1705
- for (const dir of sorted) {
1706
- const healthColor = dir.health >= 70 ? chalk.green : dir.health >= 50 ? chalk.yellow : chalk.red;
1707
- const purityColor = dir.purity >= 70 ? chalk.green : dir.purity >= 50 ? chalk.yellow : chalk.red;
1708
- const relativePath = toRelativePath(dir.dirPath);
1709
- const total = dir.pureCount + dir.impureCount;
1710
- console.log(
1711
- padEnd(relativePath, 45) + healthColor(padEnd(dir.health.toFixed(0) + "%", 10)) + purityColor(padEnd(dir.purity.toFixed(0) + "%", 10)) + padEnd(`${dir.pureCount}/${total}`, 15)
1712
- );
1713
- }
1714
- console.log();
1715
- }
1716
1899
  function printWorstDirectories(directories) {
1717
1900
  const sorted = [...directories].filter((d) => d.pureCount + d.impureCount > 0 && d.health < 70).sort((a, b) => a.health - b.health).slice(0, 5);
1718
1901
  if (sorted.length === 0) return;
1719
1902
  console.log(chalk.bold("Directories Needing Attention:"));
1720
1903
  for (const dir of sorted) {
1721
- const healthColor = dir.health >= 50 ? chalk.yellow : chalk.red;
1904
+ const healthColor = getMetricColor(dir.health);
1722
1905
  const total = dir.pureCount + dir.impureCount;
1723
1906
  const relativePath = toRelativePath(dir.dirPath);
1724
1907
  console.log(
@@ -1784,13 +1967,7 @@ function createProgressBar(percent, width) {
1784
1967
  const filledChar = "\u2588";
1785
1968
  const emptyChar = "\u2591";
1786
1969
  const bar = filledChar.repeat(filled) + emptyChar.repeat(empty);
1787
- if (percent >= 70) {
1788
- return chalk.green(bar);
1789
- } else if (percent >= 50) {
1790
- return chalk.yellow(bar);
1791
- } else {
1792
- return chalk.red(bar);
1793
- }
1970
+ return getMetricColor(percent)(bar);
1794
1971
  }
1795
1972
  function generateSummaryLine(score) {
1796
1973
  const parts = [
@@ -1802,20 +1979,12 @@ function generateSummaryLine(score) {
1802
1979
  }
1803
1980
  return parts.join(" | ");
1804
1981
  }
1805
- function toRelativePath(absolutePath) {
1806
- const cwd = process.cwd();
1807
- if (absolutePath.startsWith(cwd)) {
1808
- const relative2 = path.relative(cwd, absolutePath);
1809
- return relative2 || ".";
1810
- }
1811
- return absolutePath;
1812
- }
1813
1982
  function padEnd(str, length) {
1814
1983
  if (str.length >= length) return str.slice(0, length);
1815
1984
  return str + " ".repeat(length - str.length);
1816
1985
  }
1817
1986
  function printFileReport(fileScore) {
1818
- const healthColor = fileScore.health >= 70 ? chalk.green : fileScore.health >= 50 ? chalk.yellow : chalk.red;
1987
+ const healthColor = getMetricColor(fileScore.health);
1819
1988
  const total = fileScore.pureCount + fileScore.impureCount;
1820
1989
  const relativePath = toRelativePath(fileScore.filePath);
1821
1990
  console.log();