fcis 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { cli } from 'cleye';
3
- import { z } from 'zod';
4
3
  import chalk from 'chalk';
5
4
  import * as fs3 from 'fs';
6
5
  import * as path4 from 'path';
7
6
  import { Project, SyntaxKind } from 'ts-morph';
7
+ import * as jsonc from 'jsonc-parser';
8
+ import { z } from 'zod';
8
9
 
9
10
  var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
10
11
  get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
@@ -18,6 +19,31 @@ var DEFAULT_QUALITY_THRESHOLDS = {
18
19
  okThreshold: 70,
19
20
  reviewThreshold: 40
20
21
  };
22
+ var INLINE_CALLBACK_METHODS = /* @__PURE__ */ new Set([
23
+ // Array methods
24
+ "map",
25
+ "filter",
26
+ "reduce",
27
+ "forEach",
28
+ "find",
29
+ "findIndex",
30
+ "some",
31
+ "every",
32
+ "flatMap",
33
+ "sort",
34
+ "toSorted",
35
+ // Promise methods
36
+ "then",
37
+ "catch",
38
+ "finally",
39
+ // Timing
40
+ "setTimeout",
41
+ "setInterval",
42
+ // Events
43
+ "addEventListener",
44
+ "on",
45
+ "once"
46
+ ]);
21
47
  function extractFunctions(sourceFile) {
22
48
  const functions = [];
23
49
  const filePath = sourceFile.getFilePath();
@@ -105,25 +131,54 @@ function extractFunctions(sourceFile) {
105
131
  if (node.getKind() === SyntaxKind.ArrowFunction) {
106
132
  const arrowFn = node;
107
133
  const parentContext = inferParentContext(arrowFn);
134
+ const isInlineCallback = isInlineCallbackToKnownHOF(parentContext);
135
+ const enclosingFunctionStartLine = isInlineCallback ? findEnclosingFunctionStartLine(arrowFn) : null;
108
136
  addFunction(
109
- extractFunctionData(arrowFn, filePath, "arrow", parentContext, false)
137
+ extractFunctionData(
138
+ arrowFn,
139
+ filePath,
140
+ "arrow",
141
+ parentContext,
142
+ false,
143
+ isInlineCallback,
144
+ enclosingFunctionStartLine
145
+ )
110
146
  );
111
147
  } else if (node.getKind() === SyntaxKind.FunctionExpression) {
112
148
  const funcExpr = node;
113
149
  const parentContext = inferParentContext(funcExpr);
150
+ const isInlineCallback = isInlineCallbackToKnownHOF(parentContext);
151
+ const enclosingFunctionStartLine = isInlineCallback ? findEnclosingFunctionStartLine(funcExpr) : null;
114
152
  addFunction(
115
153
  extractFunctionData(
116
154
  funcExpr,
117
155
  filePath,
118
156
  "function-expression",
119
157
  parentContext,
120
- false
158
+ false,
159
+ isInlineCallback,
160
+ enclosingFunctionStartLine
121
161
  )
122
162
  );
123
163
  }
124
164
  });
125
165
  return functions;
126
166
  }
167
+ function isInlineCallbackToKnownHOF(parentContext) {
168
+ if (parentContext === null) return false;
169
+ return INLINE_CALLBACK_METHODS.has(parentContext);
170
+ }
171
+ function findEnclosingFunctionStartLine(node) {
172
+ let current = node.getParent();
173
+ while (current) {
174
+ const kind = current.getKind();
175
+ if (kind === SyntaxKind.FunctionDeclaration || kind === SyntaxKind.MethodDeclaration || kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.FunctionExpression || kind === SyntaxKind.GetAccessor || kind === SyntaxKind.SetAccessor) {
176
+ return current.getStartLineNumber();
177
+ }
178
+ current = current.getParent();
179
+ }
180
+ return null;
181
+ }
127
182
  function inferParentContext(node) {
128
183
  const parent = node.getParent();
129
184
  if (!parent) return null;
@@ -148,7 +203,7 @@ function inferParentContext(node) {
148
203
  }
149
204
  return null;
150
205
  }
151
- function extractFunctionData(node, filePath, kind, parentContext = null, isExportedOverride) {
206
+ function extractFunctionData(node, filePath, kind, parentContext = null, isExportedOverride, isInlineCallback = false, enclosingFunctionStartLine = null) {
152
207
  const startLine = node.getStartLineNumber();
153
208
  const endLine = node.getEndLineNumber();
154
209
  const bodyLineCount = endLine - startLine + 1;
@@ -184,7 +239,9 @@ function extractFunctionData(node, filePath, kind, parentContext = null, isExpor
184
239
  callSites,
185
240
  hasAwait,
186
241
  propertyAccessChains,
187
- kind
242
+ kind,
243
+ enclosingFunctionStartLine,
244
+ isInlineCallback
188
245
  };
189
246
  }
190
247
  function getBodyNode(node) {
@@ -210,8 +267,12 @@ function isStatement(node) {
210
267
  function checkForConditionals(body) {
211
268
  if (!body) return false;
212
269
  let hasConditionals = false;
213
- body.forEachDescendant((node) => {
270
+ body.forEachDescendant((node, traversal) => {
214
271
  const kind = node.getKind();
272
+ if (kind === SyntaxKind.FunctionDeclaration || kind === SyntaxKind.FunctionExpression || kind === SyntaxKind.ArrowFunction || kind === SyntaxKind.MethodDeclaration) {
273
+ traversal.skip();
274
+ return;
275
+ }
215
276
  if (kind === SyntaxKind.IfStatement || kind === SyntaxKind.ConditionalExpression || kind === SyntaxKind.SwitchStatement) {
216
277
  hasConditionals = true;
217
278
  }
@@ -341,73 +402,30 @@ function isTypeOnlyFile(sourceFile) {
341
402
  }
342
403
  return true;
343
404
  }
344
- function stripJsonComments(jsonString) {
345
- let result = "";
346
- let inString = false;
347
- let inLineComment = false;
348
- let inBlockComment = false;
349
- let i = 0;
350
- while (i < jsonString.length) {
351
- const char = jsonString[i];
352
- const nextChar = jsonString[i + 1];
353
- if (!inLineComment && !inBlockComment && char === '"') {
354
- let backslashCount = 0;
355
- let j = i - 1;
356
- while (j >= 0 && jsonString[j] === "\\") {
357
- backslashCount++;
358
- j--;
359
- }
360
- if (backslashCount % 2 === 0) {
361
- inString = !inString;
362
- }
363
- }
364
- if (inString) {
365
- result += char;
366
- i++;
367
- continue;
368
- }
369
- if (!inBlockComment && char === "/" && nextChar === "/") {
370
- inLineComment = true;
371
- i += 2;
372
- continue;
373
- }
374
- if (inLineComment && (char === "\n" || char === "\r")) {
375
- inLineComment = false;
376
- result += char;
377
- i++;
378
- continue;
379
- }
380
- if (!inLineComment && char === "/" && nextChar === "*") {
381
- inBlockComment = true;
382
- i += 2;
383
- continue;
384
- }
385
- if (inBlockComment && char === "*" && nextChar === "/") {
386
- inBlockComment = false;
387
- i += 2;
388
- continue;
389
- }
390
- if (!inLineComment && !inBlockComment) {
391
- result += char;
392
- }
393
- i++;
405
+ function validateTsconfigContent(content) {
406
+ const errors = [];
407
+ jsonc.parse(content, errors, { allowTrailingComma: true });
408
+ if (errors.length > 0 && errors[0]) {
409
+ return {
410
+ valid: false,
411
+ error: `Parse error at offset ${errors[0].offset}: ${jsonc.printParseErrorCode(errors[0].error)}`
412
+ };
394
413
  }
395
- return result;
414
+ return { valid: true };
396
415
  }
397
416
  function loadProject(options) {
398
417
  const { tsconfigPath, filesGlob } = options;
399
418
  const errors = [];
400
419
  const absoluteTsconfigPath = path4.resolve(tsconfigPath);
420
+ const projectDir = path4.dirname(absoluteTsconfigPath);
401
421
  if (!fs3.existsSync(absoluteTsconfigPath)) {
402
422
  throw new Error(`tsconfig.json not found at: ${absoluteTsconfigPath}`);
403
423
  }
404
- try {
405
- const content = fs3.readFileSync(absoluteTsconfigPath, "utf-8");
406
- const strippedContent = stripJsonComments(content);
407
- JSON.parse(strippedContent);
408
- } catch (e) {
424
+ const content = fs3.readFileSync(absoluteTsconfigPath, "utf-8");
425
+ const validationResult = validateTsconfigContent(content);
426
+ if (!validationResult.valid) {
409
427
  throw new Error(
410
- `Invalid tsconfig.json at ${absoluteTsconfigPath}: ${e instanceof Error ? e.message : String(e)}`
428
+ `Invalid tsconfig.json at ${absoluteTsconfigPath}: ${validationResult.error}`
411
429
  );
412
430
  }
413
431
  const project = new Project({
@@ -416,7 +434,7 @@ function loadProject(options) {
416
434
  });
417
435
  let sourceFiles = project.getSourceFiles().filter((sf) => {
418
436
  const filePath = sf.getFilePath();
419
- return filePath.endsWith(".ts") && !filePath.endsWith(".d.ts") && !filePath.endsWith(".test.ts") && !filePath.endsWith(".spec.ts") && !filePath.includes("/node_modules/") && !filePath.includes("/generated/");
437
+ 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/");
420
438
  });
421
439
  if (filesGlob) {
422
440
  const globPattern = createGlobMatcher(filesGlob);
@@ -977,6 +995,122 @@ function deriveStatus(classification, qualityScore, thresholds = DEFAULT_QUALITY
977
995
  }
978
996
 
979
997
  // src/scoring/scorer.ts
998
+ function groupFunctionsByParent(functions) {
999
+ const byStartLine = /* @__PURE__ */ new Map();
1000
+ for (const fn of functions) {
1001
+ byStartLine.set(fn.startLine, fn);
1002
+ }
1003
+ const topLevel = [];
1004
+ const childrenByParentLine = /* @__PURE__ */ new Map();
1005
+ for (const fn of functions) {
1006
+ const isTopLevel = !fn.isInlineCallback || fn.enclosingFunctionStartLine === null;
1007
+ if (isTopLevel) {
1008
+ topLevel.push({ fn, children: [] });
1009
+ } else if (fn.enclosingFunctionStartLine !== null) {
1010
+ const existing = childrenByParentLine.get(fn.enclosingFunctionStartLine);
1011
+ if (existing) {
1012
+ existing.push(fn);
1013
+ } else {
1014
+ childrenByParentLine.set(fn.enclosingFunctionStartLine, [fn]);
1015
+ }
1016
+ }
1017
+ }
1018
+ for (const entry of topLevel) {
1019
+ const children = childrenByParentLine.get(entry.fn.startLine);
1020
+ if (children) {
1021
+ entry.children = children;
1022
+ }
1023
+ }
1024
+ return topLevel;
1025
+ }
1026
+ function isEffectivelyImpure(entry) {
1027
+ if (entry.fn.classification === "impure") return true;
1028
+ return entry.children.some((child) => child.classification === "impure");
1029
+ }
1030
+ function getEffectiveStatus(entry) {
1031
+ const statuses = [entry.fn.status, ...entry.children.map((c) => c.status)];
1032
+ if (statuses.includes("refactor")) return "refactor";
1033
+ if (statuses.includes("review")) return "review";
1034
+ return "ok";
1035
+ }
1036
+ function computeComposedQuality(entry) {
1037
+ const impureChildren = entry.children.filter(
1038
+ (c) => c.classification === "impure"
1039
+ );
1040
+ if (impureChildren.length === 0) {
1041
+ return entry.fn.qualityScore ?? 50;
1042
+ }
1043
+ const childTotalLines = entry.children.reduce(
1044
+ (sum, c) => sum + c.bodyLineCount,
1045
+ 0
1046
+ );
1047
+ const parentOwnLines = Math.max(1, entry.fn.bodyLineCount - childTotalLines);
1048
+ const totalLines = parentOwnLines + childTotalLines;
1049
+ const parentQuality = entry.fn.qualityScore ?? 50;
1050
+ const parentContribution = parentQuality * parentOwnLines;
1051
+ const childContribution = impureChildren.reduce(
1052
+ (sum, c) => sum + (c.qualityScore ?? 50) * c.bodyLineCount,
1053
+ 0
1054
+ );
1055
+ return (parentContribution + childContribution) / totalLines;
1056
+ }
1057
+ function aggregateMetrics(items) {
1058
+ const scorableItems = items.filter(
1059
+ (item) => item.pureCount + item.impureCount > 0
1060
+ );
1061
+ const totalPure = items.reduce((sum, item) => sum + item.pureCount, 0);
1062
+ const totalImpure = items.reduce((sum, item) => sum + item.impureCount, 0);
1063
+ const total = totalPure + totalImpure;
1064
+ if (total === 0) {
1065
+ return {
1066
+ purity: 100,
1067
+ impurityQuality: null,
1068
+ health: 100,
1069
+ pureCount: 0,
1070
+ impureCount: 0,
1071
+ excludedCount: items.reduce((sum, item) => sum + item.excludedCount, 0),
1072
+ statusBreakdown: { ok: 0, review: 0, refactor: 0 },
1073
+ pureLineCount: 0,
1074
+ impureLineCount: 0
1075
+ };
1076
+ }
1077
+ const purity = totalPure / total * 100;
1078
+ let impurityQuality = null;
1079
+ if (totalImpure > 0) {
1080
+ const weightedQuality = scorableItems.reduce((sum, item) => {
1081
+ if (item.impurityQuality !== null && item.impureCount > 0) {
1082
+ return sum + item.impurityQuality * item.impureCount;
1083
+ }
1084
+ return sum;
1085
+ }, 0);
1086
+ impurityQuality = weightedQuality / totalImpure;
1087
+ }
1088
+ const statusBreakdown = {
1089
+ ok: items.reduce((sum, item) => sum + item.statusBreakdown.ok, 0),
1090
+ review: items.reduce((sum, item) => sum + item.statusBreakdown.review, 0),
1091
+ refactor: items.reduce(
1092
+ (sum, item) => sum + item.statusBreakdown.refactor,
1093
+ 0
1094
+ )
1095
+ };
1096
+ const health = statusBreakdown.ok / total * 100;
1097
+ const pureLineCount = items.reduce((sum, item) => sum + item.pureLineCount, 0);
1098
+ const impureLineCount = items.reduce(
1099
+ (sum, item) => sum + item.impureLineCount,
1100
+ 0
1101
+ );
1102
+ return {
1103
+ purity,
1104
+ impurityQuality,
1105
+ health,
1106
+ pureCount: totalPure,
1107
+ impureCount: totalImpure,
1108
+ excludedCount: items.reduce((sum, item) => sum + item.excludedCount, 0),
1109
+ statusBreakdown,
1110
+ pureLineCount,
1111
+ impureLineCount
1112
+ };
1113
+ }
980
1114
  function scoreFile(filePath, functions, isTypeOnly = false) {
981
1115
  if (isTypeOnly) {
982
1116
  return {
@@ -995,12 +1129,8 @@ function scoreFile(filePath, functions, isTypeOnly = false) {
995
1129
  typeOnly: true
996
1130
  };
997
1131
  }
998
- const pureFunctions = functions.filter((f) => f.classification === "pure");
999
- const impureFunctions = functions.filter((f) => f.classification === "impure");
1000
- const pureCount = pureFunctions.length;
1001
- const impureCount = impureFunctions.length;
1002
- const total = pureCount + impureCount;
1003
- if (total === 0) {
1132
+ const grouped = groupFunctionsByParent(functions);
1133
+ if (grouped.length === 0) {
1004
1134
  return {
1005
1135
  filePath,
1006
1136
  purity: 100,
@@ -1017,36 +1147,49 @@ function scoreFile(filePath, functions, isTypeOnly = false) {
1017
1147
  allExcluded: true
1018
1148
  };
1019
1149
  }
1020
- const purity = pureCount / total * 100;
1150
+ let pureCount = 0;
1151
+ let impureCount = 0;
1152
+ let pureLineCount = 0;
1153
+ let impureLineCount = 0;
1154
+ const statusBreakdown = { ok: 0, review: 0, refactor: 0 };
1155
+ for (const entry of grouped) {
1156
+ const effectivelyImpure = isEffectivelyImpure(entry);
1157
+ const effectiveStatus = getEffectiveStatus(entry);
1158
+ if (effectivelyImpure) {
1159
+ impureCount++;
1160
+ impureLineCount += entry.fn.bodyLineCount;
1161
+ } else {
1162
+ pureCount++;
1163
+ pureLineCount += entry.fn.bodyLineCount;
1164
+ }
1165
+ statusBreakdown[effectiveStatus]++;
1166
+ }
1167
+ const total = pureCount + impureCount;
1168
+ const purity = total > 0 ? pureCount / total * 100 : 100;
1021
1169
  let impurityQuality = null;
1022
1170
  if (impureCount > 0) {
1023
- const totalQuality = impureFunctions.reduce(
1024
- (sum, f) => sum + (f.qualityScore ?? 0),
1171
+ const impureEntries = grouped.filter((e) => isEffectivelyImpure(e));
1172
+ const totalQuality = impureEntries.reduce(
1173
+ (sum, e) => sum + computeComposedQuality(e),
1025
1174
  0
1026
1175
  );
1027
1176
  impurityQuality = totalQuality / impureCount;
1028
1177
  }
1029
- const statusBreakdown = {
1030
- ok: functions.filter((f) => f.status === "ok").length,
1031
- review: functions.filter((f) => f.status === "review").length,
1032
- refactor: functions.filter((f) => f.status === "refactor").length
1033
- };
1034
- const health = statusBreakdown.ok / total * 100;
1035
- const pureLineCount = pureFunctions.reduce(
1036
- (sum, f) => sum + f.bodyLineCount,
1037
- 0
1038
- );
1039
- const impureLineCount = impureFunctions.reduce(
1040
- (sum, f) => sum + f.bodyLineCount,
1041
- 0
1042
- );
1043
- const refactoringCandidates = impureFunctions.filter((f) => f.status === "refactor").map((f) => ({
1044
- name: f.name,
1045
- startLine: f.startLine,
1046
- bodyLineCount: f.bodyLineCount,
1047
- qualityScore: f.qualityScore ?? 0,
1048
- markers: f.markers.map((m) => m.type)
1049
- })).sort((a, b) => {
1178
+ const health = total > 0 ? statusBreakdown.ok / total * 100 : 100;
1179
+ const refactoringCandidates = grouped.filter((e) => isEffectivelyImpure(e) && getEffectiveStatus(e) === "refactor").map((e) => {
1180
+ const allMarkers = [
1181
+ ...e.fn.markers.map((m) => m.type),
1182
+ ...e.children.flatMap((c) => c.markers.map((m) => m.type))
1183
+ ];
1184
+ const uniqueMarkers = [...new Set(allMarkers)];
1185
+ return {
1186
+ name: e.fn.name,
1187
+ startLine: e.fn.startLine,
1188
+ bodyLineCount: e.fn.bodyLineCount,
1189
+ qualityScore: e.fn.qualityScore ?? 0,
1190
+ markers: uniqueMarkers
1191
+ };
1192
+ }).sort((a, b) => {
1050
1193
  const impactA = a.bodyLineCount * (100 - a.qualityScore);
1051
1194
  const impactB = b.bodyLineCount * (100 - b.qualityScore);
1052
1195
  return impactB - impactA;
@@ -1063,6 +1206,7 @@ function scoreFile(filePath, functions, isTypeOnly = false) {
1063
1206
  pureLineCount,
1064
1207
  impureLineCount,
1065
1208
  functions,
1209
+ // Keep all functions for drill-down (includes isInlineCallback flag)
1066
1210
  refactoringCandidates
1067
1211
  };
1068
1212
  }
@@ -1071,62 +1215,19 @@ function scoreDirectory(dirPath, fileScores) {
1071
1215
  (f) => !f.typeOnly && !f.allExcluded && f.pureCount + f.impureCount > 0
1072
1216
  );
1073
1217
  if (scorableFiles.length === 0) {
1218
+ const emptyMetrics = aggregateMetrics([]);
1074
1219
  return {
1075
1220
  dirPath,
1076
- purity: 100,
1077
- impurityQuality: null,
1078
- health: 100,
1079
- pureCount: 0,
1080
- impureCount: 0,
1221
+ ...emptyMetrics,
1081
1222
  excludedCount: fileScores.reduce((sum, f) => sum + f.excludedCount, 0),
1082
- statusBreakdown: { ok: 0, review: 0, refactor: 0 },
1083
- pureLineCount: 0,
1084
- impureLineCount: 0,
1085
1223
  fileScores
1086
1224
  };
1087
1225
  }
1088
- const totalPure = scorableFiles.reduce((sum, f) => sum + f.pureCount, 0);
1089
- const totalImpure = scorableFiles.reduce((sum, f) => sum + f.impureCount, 0);
1090
- const total = totalPure + totalImpure;
1091
- const purity = total > 0 ? totalPure / total * 100 : 100;
1092
- let impurityQuality = null;
1093
- if (totalImpure > 0) {
1094
- const weightedQuality = scorableFiles.reduce((sum, f) => {
1095
- if (f.impurityQuality !== null && f.impureCount > 0) {
1096
- return sum + f.impurityQuality * f.impureCount;
1097
- }
1098
- return sum;
1099
- }, 0);
1100
- impurityQuality = weightedQuality / totalImpure;
1101
- }
1102
- const statusBreakdown = {
1103
- ok: scorableFiles.reduce((sum, f) => sum + f.statusBreakdown.ok, 0),
1104
- review: scorableFiles.reduce((sum, f) => sum + f.statusBreakdown.review, 0),
1105
- refactor: scorableFiles.reduce(
1106
- (sum, f) => sum + f.statusBreakdown.refactor,
1107
- 0
1108
- )
1109
- };
1110
- const health = total > 0 ? statusBreakdown.ok / total * 100 : 100;
1111
- const pureLineCount = scorableFiles.reduce(
1112
- (sum, f) => sum + f.pureLineCount,
1113
- 0
1114
- );
1115
- const impureLineCount = scorableFiles.reduce(
1116
- (sum, f) => sum + f.impureLineCount,
1117
- 0
1118
- );
1226
+ const aggregated = aggregateMetrics(scorableFiles);
1119
1227
  return {
1120
1228
  dirPath,
1121
- purity,
1122
- impurityQuality,
1123
- health,
1124
- pureCount: totalPure,
1125
- impureCount: totalImpure,
1229
+ ...aggregated,
1126
1230
  excludedCount: fileScores.reduce((sum, f) => sum + f.excludedCount, 0),
1127
- statusBreakdown,
1128
- pureLineCount,
1129
- impureLineCount,
1130
1231
  fileScores
1131
1232
  };
1132
1233
  }
@@ -1142,19 +1243,13 @@ function scoreProject(directoryScores, options = {}) {
1142
1243
  (d) => d.pureCount + d.impureCount > 0
1143
1244
  );
1144
1245
  if (scorableDirs.length === 0) {
1246
+ const emptyMetrics = aggregateMetrics([]);
1145
1247
  return {
1146
- purity: 100,
1147
- impurityQuality: null,
1148
- health: 100,
1149
- pureCount: 0,
1150
- impureCount: 0,
1248
+ ...emptyMetrics,
1151
1249
  excludedCount: directoryScores.reduce(
1152
1250
  (sum, d) => sum + d.excludedCount,
1153
1251
  0
1154
1252
  ),
1155
- statusBreakdown: { ok: 0, review: 0, refactor: 0 },
1156
- pureLineCount: 0,
1157
- impureLineCount: 0,
1158
1253
  directoryScores,
1159
1254
  timestamp,
1160
1255
  commitHash,
@@ -1164,37 +1259,7 @@ function scoreProject(directoryScores, options = {}) {
1164
1259
  ...errors.length > 0 && { errors }
1165
1260
  };
1166
1261
  }
1167
- const totalPure = scorableDirs.reduce((sum, d) => sum + d.pureCount, 0);
1168
- const totalImpure = scorableDirs.reduce((sum, d) => sum + d.impureCount, 0);
1169
- const total = totalPure + totalImpure;
1170
- const purity = total > 0 ? totalPure / total * 100 : 100;
1171
- let impurityQuality = null;
1172
- if (totalImpure > 0) {
1173
- const weightedQuality = scorableDirs.reduce((sum, d) => {
1174
- if (d.impurityQuality !== null && d.impureCount > 0) {
1175
- return sum + d.impurityQuality * d.impureCount;
1176
- }
1177
- return sum;
1178
- }, 0);
1179
- impurityQuality = weightedQuality / totalImpure;
1180
- }
1181
- const statusBreakdown = {
1182
- ok: scorableDirs.reduce((sum, d) => sum + d.statusBreakdown.ok, 0),
1183
- review: scorableDirs.reduce((sum, d) => sum + d.statusBreakdown.review, 0),
1184
- refactor: scorableDirs.reduce(
1185
- (sum, d) => sum + d.statusBreakdown.refactor,
1186
- 0
1187
- )
1188
- };
1189
- const health = total > 0 ? statusBreakdown.ok / total * 100 : 100;
1190
- const pureLineCount = scorableDirs.reduce(
1191
- (sum, d) => sum + d.pureLineCount,
1192
- 0
1193
- );
1194
- const impureLineCount = scorableDirs.reduce(
1195
- (sum, d) => sum + d.impureLineCount,
1196
- 0
1197
- );
1262
+ const aggregated = aggregateMetrics(scorableDirs);
1198
1263
  const allCandidates = [];
1199
1264
  for (const dir of directoryScores) {
1200
1265
  for (const file of dir.fileScores) {
@@ -1212,15 +1277,8 @@ function scoreProject(directoryScores, options = {}) {
1212
1277
  return impactB - impactA;
1213
1278
  });
1214
1279
  const result = {
1215
- purity,
1216
- impurityQuality,
1217
- health,
1218
- pureCount: totalPure,
1219
- impureCount: totalImpure,
1280
+ ...aggregated,
1220
1281
  excludedCount: directoryScores.reduce((sum, d) => sum + d.excludedCount, 0),
1221
- statusBreakdown,
1222
- pureLineCount,
1223
- impureLineCount,
1224
1282
  directoryScores,
1225
1283
  timestamp,
1226
1284
  commitHash,
@@ -1253,6 +1311,56 @@ function getDirectoryPath(filePath) {
1253
1311
  if (lastSlash === -1) return ".";
1254
1312
  return filePath.slice(0, lastSlash);
1255
1313
  }
1314
+ function getPathAtDepth(relativePath, depth) {
1315
+ const normalized = relativePath.replace(/\\/g, "/");
1316
+ const segments = normalized.split("/").filter(Boolean);
1317
+ if (segments.length === 0) {
1318
+ return ".";
1319
+ }
1320
+ const segmentCount = depth + 1;
1321
+ if (segmentCount >= segments.length) {
1322
+ return segments.join("/");
1323
+ }
1324
+ return segments.slice(0, segmentCount).join("/");
1325
+ }
1326
+ function rollupDirectoriesByDepth(directories, depth) {
1327
+ if (directories.length === 0) {
1328
+ return [];
1329
+ }
1330
+ const groups = /* @__PURE__ */ new Map();
1331
+ for (const dir of directories) {
1332
+ const truncatedPath = getPathAtDepth(dir.dirPath, depth);
1333
+ const existing = groups.get(truncatedPath);
1334
+ if (existing) {
1335
+ existing.push(dir);
1336
+ } else {
1337
+ groups.set(truncatedPath, [dir]);
1338
+ }
1339
+ }
1340
+ const rolledUp = [];
1341
+ for (const [groupPath, groupDirs] of groups) {
1342
+ const scorableDirs = groupDirs.filter((d) => d.pureCount + d.impureCount > 0);
1343
+ if (scorableDirs.length === 0) {
1344
+ const emptyMetrics = aggregateMetrics([]);
1345
+ rolledUp.push({
1346
+ dirPath: groupPath,
1347
+ ...emptyMetrics,
1348
+ excludedCount: groupDirs.reduce((sum, d) => sum + d.excludedCount, 0),
1349
+ fileScores: []
1350
+ });
1351
+ continue;
1352
+ }
1353
+ const aggregated = aggregateMetrics(scorableDirs);
1354
+ rolledUp.push({
1355
+ dirPath: groupPath,
1356
+ ...aggregated,
1357
+ excludedCount: groupDirs.reduce((sum, d) => sum + d.excludedCount, 0),
1358
+ fileScores: []
1359
+ });
1360
+ }
1361
+ rolledUp.sort((a, b) => a.dirPath.localeCompare(b.dirPath));
1362
+ return rolledUp;
1363
+ }
1256
1364
  function getDiagnosticInsights(score) {
1257
1365
  const insights = [];
1258
1366
  if (score.purity >= 60 && score.impurityQuality !== null && score.impurityQuality < 50) {
@@ -1390,8 +1498,79 @@ function checkThresholds(score, config) {
1390
1498
  failures
1391
1499
  };
1392
1500
  }
1501
+ function getMetricColor(value) {
1502
+ if (value >= 70) return chalk.green;
1503
+ if (value >= 50) return chalk.yellow;
1504
+ return chalk.red;
1505
+ }
1506
+ function getMetricColorNullable(value) {
1507
+ if (value === null) return chalk.gray;
1508
+ return getMetricColor(value);
1509
+ }
1510
+ function toRelativePath(absolutePath) {
1511
+ const cwd = process.cwd();
1512
+ if (absolutePath.startsWith(cwd)) {
1513
+ const relative3 = path4.relative(cwd, absolutePath);
1514
+ return relative3 || ".";
1515
+ }
1516
+ return absolutePath;
1517
+ }
1518
+ function relativizeDirectoryPaths(directories, projectRoot) {
1519
+ return directories.map((d) => ({
1520
+ ...d,
1521
+ dirPath: path4.relative(projectRoot, d.dirPath).replace(/\\/g, "/")
1522
+ }));
1523
+ }
1524
+ function printDirectoryTable(directories, options) {
1525
+ const { title, includeQualityColumn, sortBy, emptyMessage } = options;
1526
+ let filtered = directories.filter((d) => d.pureCount + d.impureCount > 0);
1527
+ if (sortBy === "health-asc") {
1528
+ filtered = [...filtered].sort((a, b) => a.health - b.health);
1529
+ }
1530
+ console.log(chalk.bold(title));
1531
+ console.log(chalk.gray("\u2500".repeat(80)));
1532
+ if (includeQualityColumn) {
1533
+ console.log(
1534
+ chalk.gray(
1535
+ padEnd("Directory", 40) + padEnd("Health", 10) + padEnd("Purity", 10) + padEnd("Quality", 10) + padEnd("Functions", 10)
1536
+ )
1537
+ );
1538
+ } else {
1539
+ console.log(
1540
+ chalk.gray(
1541
+ padEnd("Directory", 45) + padEnd("Health", 10) + padEnd("Purity", 10) + padEnd("Functions", 15)
1542
+ )
1543
+ );
1544
+ }
1545
+ console.log(chalk.gray("\u2500".repeat(80)));
1546
+ if (filtered.length === 0) {
1547
+ if (emptyMessage) {
1548
+ console.log(chalk.gray(` ${emptyMessage}`));
1549
+ }
1550
+ console.log();
1551
+ return;
1552
+ }
1553
+ for (const dir of filtered) {
1554
+ const healthColor = getMetricColor(dir.health);
1555
+ const purityColor = getMetricColor(dir.purity);
1556
+ const total = dir.pureCount + dir.impureCount;
1557
+ if (includeQualityColumn) {
1558
+ const qualityStr = dir.impurityQuality !== null ? dir.impurityQuality.toFixed(0) + "%" : "\u2014";
1559
+ const qualityColor = getMetricColorNullable(dir.impurityQuality);
1560
+ console.log(
1561
+ 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)
1562
+ );
1563
+ } else {
1564
+ const relativePath = toRelativePath(dir.dirPath);
1565
+ console.log(
1566
+ padEnd(relativePath, 45) + healthColor(padEnd(dir.health.toFixed(0) + "%", 10)) + purityColor(padEnd(dir.purity.toFixed(0) + "%", 10)) + padEnd(`${dir.pureCount}/${total}`, 15)
1567
+ );
1568
+ }
1569
+ }
1570
+ console.log();
1571
+ }
1393
1572
  function printConsoleReport(score, options = {}) {
1394
- const { verbose = false } = options;
1573
+ const { verbose = false, dirDepth } = options;
1395
1574
  printHeader();
1396
1575
  printProjectSummary(score);
1397
1576
  printStatusBreakdown(
@@ -1399,8 +1578,24 @@ function printConsoleReport(score, options = {}) {
1399
1578
  score.pureCount + score.impureCount
1400
1579
  );
1401
1580
  printInsights(score);
1402
- if (verbose) {
1403
- printDirectoryBreakdown(score.directoryScores);
1581
+ if (dirDepth !== void 0) {
1582
+ const relativized = relativizeDirectoryPaths(
1583
+ score.directoryScores,
1584
+ process.cwd()
1585
+ );
1586
+ const rolledUp = rollupDirectoriesByDepth(relativized, dirDepth);
1587
+ printDirectoryTable(rolledUp, {
1588
+ title: `Directory Breakdown (depth=${dirDepth}):`,
1589
+ includeQualityColumn: true,
1590
+ sortBy: "preserve-order",
1591
+ emptyMessage: "No directories with functions found"
1592
+ });
1593
+ } else if (verbose) {
1594
+ printDirectoryTable(score.directoryScores, {
1595
+ title: "Directory Breakdown:",
1596
+ includeQualityColumn: false,
1597
+ sortBy: "health-asc"
1598
+ });
1404
1599
  } else {
1405
1600
  printWorstDirectories(score.directoryScores);
1406
1601
  }
@@ -1420,8 +1615,8 @@ function printHeader() {
1420
1615
  }
1421
1616
  function printProjectSummary(score) {
1422
1617
  const healthBar = createProgressBar(score.health, 25);
1423
- const healthColor = score.health >= 70 ? chalk.green : score.health >= 50 ? chalk.yellow : chalk.red;
1424
- const purityColor = score.purity >= 70 ? chalk.green : score.purity >= 50 ? chalk.yellow : chalk.red;
1618
+ const healthColor = getMetricColor(score.health);
1619
+ const purityColor = getMetricColor(score.purity);
1425
1620
  console.log(
1426
1621
  `Project Health: ${healthColor(score.health.toFixed(0) + "%")} ${healthBar}`
1427
1622
  );
@@ -1429,7 +1624,7 @@ function printProjectSummary(score) {
1429
1624
  ` Purity: ${purityColor(score.purity.toFixed(0) + "%")} (${score.pureCount} pure / ${score.pureCount + score.impureCount} total)`
1430
1625
  );
1431
1626
  if (score.impurityQuality !== null) {
1432
- const qualityColor = score.impurityQuality >= 70 ? chalk.green : score.impurityQuality >= 50 ? chalk.yellow : chalk.red;
1627
+ const qualityColor = getMetricColor(score.impurityQuality);
1433
1628
  console.log(
1434
1629
  ` Impurity Quality: ${qualityColor(score.impurityQuality.toFixed(0) + "%")} average`
1435
1630
  );
@@ -1462,34 +1657,12 @@ function printInsights(score) {
1462
1657
  console.log();
1463
1658
  }
1464
1659
  }
1465
- function printDirectoryBreakdown(directories) {
1466
- const sorted = [...directories].filter((d) => d.pureCount + d.impureCount > 0).sort((a, b) => a.health - b.health);
1467
- if (sorted.length === 0) return;
1468
- console.log(chalk.bold("Directory Breakdown:"));
1469
- console.log(chalk.gray("\u2500".repeat(80)));
1470
- console.log(
1471
- chalk.gray(
1472
- padEnd("Directory", 45) + padEnd("Health", 10) + padEnd("Purity", 10) + padEnd("Functions", 15)
1473
- )
1474
- );
1475
- console.log(chalk.gray("\u2500".repeat(80)));
1476
- for (const dir of sorted) {
1477
- const healthColor = dir.health >= 70 ? chalk.green : dir.health >= 50 ? chalk.yellow : chalk.red;
1478
- const purityColor = dir.purity >= 70 ? chalk.green : dir.purity >= 50 ? chalk.yellow : chalk.red;
1479
- const relativePath = toRelativePath(dir.dirPath);
1480
- const total = dir.pureCount + dir.impureCount;
1481
- console.log(
1482
- padEnd(relativePath, 45) + healthColor(padEnd(dir.health.toFixed(0) + "%", 10)) + purityColor(padEnd(dir.purity.toFixed(0) + "%", 10)) + padEnd(`${dir.pureCount}/${total}`, 15)
1483
- );
1484
- }
1485
- console.log();
1486
- }
1487
1660
  function printWorstDirectories(directories) {
1488
1661
  const sorted = [...directories].filter((d) => d.pureCount + d.impureCount > 0 && d.health < 70).sort((a, b) => a.health - b.health).slice(0, 5);
1489
1662
  if (sorted.length === 0) return;
1490
1663
  console.log(chalk.bold("Directories Needing Attention:"));
1491
1664
  for (const dir of sorted) {
1492
- const healthColor = dir.health >= 50 ? chalk.yellow : chalk.red;
1665
+ const healthColor = getMetricColor(dir.health);
1493
1666
  const total = dir.pureCount + dir.impureCount;
1494
1667
  const relativePath = toRelativePath(dir.dirPath);
1495
1668
  console.log(
@@ -1555,13 +1728,7 @@ function createProgressBar(percent, width) {
1555
1728
  const filledChar = "\u2588";
1556
1729
  const emptyChar = "\u2591";
1557
1730
  const bar = filledChar.repeat(filled) + emptyChar.repeat(empty);
1558
- if (percent >= 70) {
1559
- return chalk.green(bar);
1560
- } else if (percent >= 50) {
1561
- return chalk.yellow(bar);
1562
- } else {
1563
- return chalk.red(bar);
1564
- }
1731
+ return getMetricColor(percent)(bar);
1565
1732
  }
1566
1733
  function generateSummaryLine(score) {
1567
1734
  const parts = [
@@ -1573,21 +1740,29 @@ function generateSummaryLine(score) {
1573
1740
  }
1574
1741
  return parts.join(" | ");
1575
1742
  }
1576
- function toRelativePath(absolutePath) {
1577
- const cwd = process.cwd();
1578
- if (absolutePath.startsWith(cwd)) {
1579
- const relative2 = path4.relative(cwd, absolutePath);
1580
- return relative2 || ".";
1581
- }
1582
- return absolutePath;
1583
- }
1584
1743
  function padEnd(str, length) {
1585
1744
  if (str.length >= length) return str.slice(0, length);
1586
1745
  return str + " ".repeat(length - str.length);
1587
1746
  }
1588
1747
  function generateJsonReport(score, options = {}) {
1589
- const { pretty = true, includeFunction = false } = options;
1590
- const reportData = prepareReportData(score, includeFunction);
1748
+ const {
1749
+ pretty = true,
1750
+ includeFunction = true,
1751
+ dirDepth,
1752
+ projectRoot = process.cwd()
1753
+ } = options;
1754
+ let reportData = prepareReportData(score, includeFunction);
1755
+ if (dirDepth !== void 0) {
1756
+ const relativized = score.directoryScores.map((d) => ({
1757
+ ...d,
1758
+ dirPath: path4.relative(projectRoot, d.dirPath).replace(/\\/g, "/")
1759
+ }));
1760
+ const rolledUp = rollupDirectoriesByDepth(relativized, dirDepth);
1761
+ reportData = {
1762
+ ...reportData,
1763
+ rolledUpDirectories: rolledUp
1764
+ };
1765
+ }
1591
1766
  if (pretty) {
1592
1767
  return JSON.stringify(reportData, null, 2);
1593
1768
  }
@@ -1617,13 +1792,175 @@ function writeJsonReport(score, outputPath, options = {}) {
1617
1792
  }
1618
1793
  fs3.writeFileSync(outputPath, json, "utf-8");
1619
1794
  }
1795
+ var VALID_FORMATS = ["console", "json", "summary"];
1796
+ var ThresholdSchema = z.number().min(0).max(100);
1797
+ var PositiveIntegerSchema = z.number().int().min(1);
1798
+ function validateCliFlags(flags) {
1799
+ if (flags.minHealth !== void 0) {
1800
+ const result = ThresholdSchema.safeParse(flags.minHealth);
1801
+ if (!result.success) {
1802
+ return {
1803
+ valid: false,
1804
+ error: "--min-health must be a number between 0 and 100"
1805
+ };
1806
+ }
1807
+ }
1808
+ if (flags.minPurity !== void 0) {
1809
+ const result = ThresholdSchema.safeParse(flags.minPurity);
1810
+ if (!result.success) {
1811
+ return {
1812
+ valid: false,
1813
+ error: "--min-purity must be a number between 0 and 100"
1814
+ };
1815
+ }
1816
+ }
1817
+ if (flags.minQuality !== void 0) {
1818
+ const result = ThresholdSchema.safeParse(flags.minQuality);
1819
+ if (!result.success) {
1820
+ return {
1821
+ valid: false,
1822
+ error: "--min-quality must be a number between 0 and 100"
1823
+ };
1824
+ }
1825
+ }
1826
+ if (!VALID_FORMATS.includes(flags.format)) {
1827
+ return {
1828
+ valid: false,
1829
+ error: `--format must be one of: ${VALID_FORMATS.join(", ")}`
1830
+ };
1831
+ }
1832
+ if (flags.dirDepth !== void 0) {
1833
+ const result = PositiveIntegerSchema.safeParse(flags.dirDepth);
1834
+ if (!result.success) {
1835
+ return {
1836
+ valid: false,
1837
+ error: "--dir-depth must be a positive integer (1 or greater)"
1838
+ };
1839
+ }
1840
+ }
1841
+ return { valid: true };
1842
+ }
1843
+ function validateGlobPattern(pattern) {
1844
+ if (!pattern || pattern.trim() === "") {
1845
+ return { valid: false, error: "Glob pattern cannot be empty" };
1846
+ }
1847
+ if (pattern.startsWith("/")) {
1848
+ return {
1849
+ valid: false,
1850
+ error: "Glob pattern cannot be an absolute path (starting with /)"
1851
+ };
1852
+ }
1853
+ let bracketDepth = 0;
1854
+ let braceDepth = 0;
1855
+ for (const char of pattern) {
1856
+ if (char === "[") bracketDepth++;
1857
+ if (char === "]") bracketDepth--;
1858
+ if (char === "{") braceDepth++;
1859
+ if (char === "}") braceDepth--;
1860
+ if (bracketDepth < 0 || braceDepth < 0) {
1861
+ return { valid: false, error: "Glob pattern has unbalanced brackets" };
1862
+ }
1863
+ }
1864
+ if (bracketDepth !== 0 || braceDepth !== 0) {
1865
+ return { valid: false, error: "Glob pattern has unbalanced brackets" };
1866
+ }
1867
+ return { valid: true };
1868
+ }
1869
+ function buildAnalyzerConfig(tsconfigPath, flags) {
1870
+ const config = {
1871
+ tsconfigPath,
1872
+ format: flags.format,
1873
+ quiet: flags.quiet,
1874
+ verbose: flags.verbose
1875
+ };
1876
+ if (flags.files !== void 0) {
1877
+ config.filesGlob = flags.files;
1878
+ }
1879
+ if (flags.minHealth !== void 0) {
1880
+ config.minHealth = flags.minHealth;
1881
+ }
1882
+ if (flags.minPurity !== void 0) {
1883
+ config.minPurity = flags.minPurity;
1884
+ }
1885
+ if (flags.minQuality !== void 0) {
1886
+ config.minQuality = flags.minQuality;
1887
+ }
1888
+ if (flags.output !== void 0) {
1889
+ config.outputPath = flags.output;
1890
+ }
1891
+ if (flags.dirDepth !== void 0) {
1892
+ config.dirDepth = flags.dirDepth;
1893
+ }
1894
+ return config;
1895
+ }
1896
+ function buildThresholdConfig(flags) {
1897
+ const config = {};
1898
+ if (flags.minHealth !== void 0) {
1899
+ config.minHealth = flags.minHealth;
1900
+ }
1901
+ if (flags.minPurity !== void 0) {
1902
+ config.minPurity = flags.minPurity;
1903
+ }
1904
+ if (flags.minQuality !== void 0) {
1905
+ config.minQuality = flags.minQuality;
1906
+ }
1907
+ return config;
1908
+ }
1620
1909
 
1621
1910
  // src/cli.ts
1622
1911
  var EXIT_SUCCESS = 0;
1623
1912
  var EXIT_THRESHOLD_FAILED = 1;
1624
1913
  var EXIT_CONFIG_ERROR = 2;
1625
1914
  var EXIT_ANALYSIS_ERROR = 3;
1626
- var ThresholdSchema = z.number().min(0).max(100);
1915
+ function handleAnalysisOutput(score, flags) {
1916
+ if (!flags.quiet) {
1917
+ if (flags.json || flags.format === "json") {
1918
+ const jsonOptions = {
1919
+ pretty: true
1920
+ };
1921
+ if (flags.dirDepth !== void 0) {
1922
+ jsonOptions.dirDepth = flags.dirDepth;
1923
+ }
1924
+ console.log(generateJsonReport(score, jsonOptions));
1925
+ } else if (flags.format === "summary") {
1926
+ console.log(generateSummaryLine(score));
1927
+ } else {
1928
+ const reportOptions = {
1929
+ verbose: flags.verbose
1930
+ };
1931
+ if (flags.dirDepth !== void 0) {
1932
+ reportOptions.dirDepth = flags.dirDepth;
1933
+ }
1934
+ printConsoleReport(score, reportOptions);
1935
+ }
1936
+ }
1937
+ if (flags.output) {
1938
+ const outputOptions = {
1939
+ pretty: true
1940
+ };
1941
+ if (flags.dirDepth !== void 0) {
1942
+ outputOptions.dirDepth = flags.dirDepth;
1943
+ }
1944
+ writeJsonReport(score, flags.output, outputOptions);
1945
+ if (!flags.quiet && flags.format !== "json") {
1946
+ console.log(chalk.green(`Report written to: ${flags.output}`));
1947
+ }
1948
+ }
1949
+ }
1950
+ function ensureOutputDirectory(outputPath) {
1951
+ const outputDir = path4.dirname(path4.resolve(outputPath));
1952
+ if (!fs3.existsSync(outputDir)) {
1953
+ try {
1954
+ fs3.mkdirSync(outputDir, { recursive: true });
1955
+ } catch {
1956
+ return {
1957
+ valid: false,
1958
+ error: `Cannot create output directory: ${outputDir}`
1959
+ };
1960
+ }
1961
+ }
1962
+ return { valid: true };
1963
+ }
1627
1964
  var argv = cli({
1628
1965
  name: "fcis",
1629
1966
  version: "0.1.0",
@@ -1671,6 +2008,10 @@ var argv = cli({
1671
2008
  alias: "v",
1672
2009
  description: "Show per-file scores and all classified functions",
1673
2010
  default: false
2011
+ },
2012
+ dirDepth: {
2013
+ type: Number,
2014
+ description: "Roll up directory metrics to depth N (e.g., 1 for top-level)"
1674
2015
  }
1675
2016
  },
1676
2017
  parameters: ["<tsconfig>"],
@@ -1699,115 +2040,42 @@ async function main() {
1699
2040
  );
1700
2041
  process.exit(EXIT_CONFIG_ERROR);
1701
2042
  }
1702
- try {
1703
- const content = fs3.readFileSync(absoluteTsconfigPath, "utf-8");
1704
- const strippedContent = stripJsonComments(content);
1705
- JSON.parse(strippedContent);
1706
- } catch (e) {
2043
+ const tsconfigContent = fs3.readFileSync(absoluteTsconfigPath, "utf-8");
2044
+ const tsconfigValidation = validateTsconfigContent(tsconfigContent);
2045
+ if (!tsconfigValidation.valid) {
1707
2046
  console.error(
1708
2047
  chalk.red(`Error: Invalid tsconfig.json at ${absoluteTsconfigPath}`)
1709
2048
  );
1710
- console.error(e instanceof Error ? e.message : String(e));
2049
+ console.error(tsconfigValidation.error);
1711
2050
  process.exit(EXIT_CONFIG_ERROR);
1712
2051
  }
1713
- if (flags.minHealth !== void 0) {
1714
- const result = ThresholdSchema.safeParse(flags.minHealth);
1715
- if (!result.success) {
1716
- console.error(
1717
- chalk.red("Error: --min-health must be a number between 0 and 100")
1718
- );
1719
- process.exit(EXIT_CONFIG_ERROR);
1720
- }
1721
- }
1722
- if (flags.minPurity !== void 0) {
1723
- const result = ThresholdSchema.safeParse(flags.minPurity);
1724
- if (!result.success) {
1725
- console.error(
1726
- chalk.red("Error: --min-purity must be a number between 0 and 100")
1727
- );
1728
- process.exit(EXIT_CONFIG_ERROR);
1729
- }
2052
+ const flagsValidation = validateCliFlags(flags);
2053
+ if (!flagsValidation.valid) {
2054
+ console.error(chalk.red(`Error: ${flagsValidation.error}`));
2055
+ process.exit(EXIT_CONFIG_ERROR);
1730
2056
  }
1731
- if (flags.minQuality !== void 0) {
1732
- const result = ThresholdSchema.safeParse(flags.minQuality);
1733
- if (!result.success) {
1734
- console.error(
1735
- chalk.red("Error: --min-quality must be a number between 0 and 100")
1736
- );
2057
+ if (flags.files !== void 0) {
2058
+ const globValidation = validateGlobPattern(flags.files);
2059
+ if (!globValidation.valid) {
2060
+ console.error(chalk.red(`Error: ${globValidation.error}`));
1737
2061
  process.exit(EXIT_CONFIG_ERROR);
1738
2062
  }
1739
2063
  }
1740
- const validFormats = ["console", "json", "summary"];
1741
- if (!validFormats.includes(flags.format)) {
1742
- console.error(
1743
- chalk.red(`Error: --format must be one of: ${validFormats.join(", ")}`)
1744
- );
1745
- process.exit(EXIT_CONFIG_ERROR);
1746
- }
1747
2064
  if (flags.output) {
1748
- const outputDir = path4.dirname(path4.resolve(flags.output));
1749
- if (!fs3.existsSync(outputDir)) {
1750
- try {
1751
- fs3.mkdirSync(outputDir, { recursive: true });
1752
- } catch (e) {
1753
- console.error(
1754
- chalk.red(`Error: Cannot create output directory: ${outputDir}`)
1755
- );
1756
- process.exit(EXIT_CONFIG_ERROR);
1757
- }
2065
+ const outputValidation = ensureOutputDirectory(flags.output);
2066
+ if (!outputValidation.valid) {
2067
+ console.error(chalk.red(`Error: ${outputValidation.error}`));
2068
+ process.exit(EXIT_CONFIG_ERROR);
1758
2069
  }
1759
2070
  }
1760
2071
  try {
1761
2072
  if (!flags.quiet && flags.format === "console") {
1762
2073
  console.log(chalk.gray(`Analyzing ${process.cwd()}...`));
1763
2074
  }
1764
- const config = {
1765
- tsconfigPath: absoluteTsconfigPath,
1766
- format: flags.format,
1767
- quiet: flags.quiet,
1768
- verbose: flags.verbose
1769
- };
1770
- if (flags.files !== void 0) {
1771
- config.filesGlob = flags.files;
1772
- }
1773
- if (flags.minHealth !== void 0) {
1774
- config.minHealth = flags.minHealth;
1775
- }
1776
- if (flags.minPurity !== void 0) {
1777
- config.minPurity = flags.minPurity;
1778
- }
1779
- if (flags.minQuality !== void 0) {
1780
- config.minQuality = flags.minQuality;
1781
- }
1782
- if (flags.output !== void 0) {
1783
- config.outputPath = flags.output;
1784
- }
2075
+ const config = buildAnalyzerConfig(absoluteTsconfigPath, flags);
1785
2076
  const score = await analyze(config);
1786
- if (!flags.quiet) {
1787
- if (flags.json || flags.format === "json") {
1788
- console.log(generateJsonReport(score, { pretty: true }));
1789
- } else if (flags.format === "summary") {
1790
- console.log(generateSummaryLine(score));
1791
- } else {
1792
- printConsoleReport(score, { verbose: flags.verbose });
1793
- }
1794
- }
1795
- if (flags.output) {
1796
- writeJsonReport(score, flags.output, { pretty: true });
1797
- if (!flags.quiet && flags.format !== "json") {
1798
- console.log(chalk.green(`Report written to: ${flags.output}`));
1799
- }
1800
- }
1801
- const thresholdConfig = {};
1802
- if (flags.minHealth !== void 0) {
1803
- thresholdConfig.minHealth = flags.minHealth;
1804
- }
1805
- if (flags.minPurity !== void 0) {
1806
- thresholdConfig.minPurity = flags.minPurity;
1807
- }
1808
- if (flags.minQuality !== void 0) {
1809
- thresholdConfig.minQuality = flags.minQuality;
1810
- }
2077
+ handleAnalysisOutput(score, flags);
2078
+ const thresholdConfig = buildThresholdConfig(flags);
1811
2079
  const thresholdResult = checkThresholds(score, thresholdConfig);
1812
2080
  if (!thresholdResult.passed) {
1813
2081
  if (!flags.quiet) {