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