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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fcis",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Functional Core, Imperative Shell analyzer for TypeScript codebases",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -22,6 +22,7 @@
22
22
  "dependencies": {
23
23
  "chalk": "^5.3.0",
24
24
  "cleye": "^1.3.2",
25
+ "jsonc-parser": "^3.3.1",
25
26
  "ts-morph": "^24.0.0",
26
27
  "ts-pattern": "^5.6.0",
27
28
  "zod": "^3.24.0"
@@ -0,0 +1,201 @@
1
+ /**
2
+ * CLI Utilities - Pure Functions
3
+ *
4
+ * Extracted pure functions for CLI validation and configuration building.
5
+ * These are separated from cli.ts to enable unit testing without triggering
6
+ * the CLI entry point.
7
+ */
8
+
9
+ import { z } from 'zod'
10
+ import type { AnalyzerConfig } from './types.js'
11
+
12
+ const VALID_FORMATS = ['console', 'json', 'summary'] as const
13
+ type OutputFormat = (typeof VALID_FORMATS)[number]
14
+
15
+ /**
16
+ * CLI flags as parsed by cleye
17
+ * Note: cleye automatically adds `version` and `help` flags
18
+ */
19
+ export type CliFlags = {
20
+ json: boolean
21
+ output: string | undefined
22
+ minHealth: number | undefined
23
+ minPurity: number | undefined
24
+ minQuality: number | undefined
25
+ files: string | undefined
26
+ format: string
27
+ quiet: boolean
28
+ verbose: boolean
29
+ dirDepth: number | undefined
30
+ version: boolean | undefined
31
+ help: boolean | undefined
32
+ }
33
+
34
+ /**
35
+ * Validation result type
36
+ */
37
+ export type ValidationResult = { valid: true } | { valid: false; error: string }
38
+
39
+ /**
40
+ * Validate a threshold value (0-100)
41
+ */
42
+ const ThresholdSchema = z.number().min(0).max(100)
43
+
44
+ /**
45
+ * Validate a positive integer (for --dir-depth)
46
+ */
47
+ const PositiveIntegerSchema = z.number().int().min(1)
48
+
49
+ /**
50
+ * Validate CLI flags (pure function)
51
+ */
52
+ export function validateCliFlags(flags: CliFlags): ValidationResult {
53
+ // Validate threshold values
54
+ if (flags.minHealth !== undefined) {
55
+ const result = ThresholdSchema.safeParse(flags.minHealth)
56
+ if (!result.success) {
57
+ return {
58
+ valid: false,
59
+ error: '--min-health must be a number between 0 and 100',
60
+ }
61
+ }
62
+ }
63
+
64
+ if (flags.minPurity !== undefined) {
65
+ const result = ThresholdSchema.safeParse(flags.minPurity)
66
+ if (!result.success) {
67
+ return {
68
+ valid: false,
69
+ error: '--min-purity must be a number between 0 and 100',
70
+ }
71
+ }
72
+ }
73
+
74
+ if (flags.minQuality !== undefined) {
75
+ const result = ThresholdSchema.safeParse(flags.minQuality)
76
+ if (!result.success) {
77
+ return {
78
+ valid: false,
79
+ error: '--min-quality must be a number between 0 and 100',
80
+ }
81
+ }
82
+ }
83
+
84
+ // Validate format
85
+ if (!VALID_FORMATS.includes(flags.format as OutputFormat)) {
86
+ return {
87
+ valid: false,
88
+ error: `--format must be one of: ${VALID_FORMATS.join(', ')}`,
89
+ }
90
+ }
91
+
92
+ // Validate dir-depth
93
+ if (flags.dirDepth !== undefined) {
94
+ const result = PositiveIntegerSchema.safeParse(flags.dirDepth)
95
+ if (!result.success) {
96
+ return {
97
+ valid: false,
98
+ error: '--dir-depth must be a positive integer (1 or greater)',
99
+ }
100
+ }
101
+ }
102
+
103
+ return { valid: true }
104
+ }
105
+
106
+ /**
107
+ * Validate a glob pattern (pure function)
108
+ */
109
+ export function validateGlobPattern(pattern: string): ValidationResult {
110
+ if (!pattern || pattern.trim() === '') {
111
+ return { valid: false, error: 'Glob pattern cannot be empty' }
112
+ }
113
+
114
+ if (pattern.startsWith('/')) {
115
+ return {
116
+ valid: false,
117
+ error: 'Glob pattern cannot be an absolute path (starting with /)',
118
+ }
119
+ }
120
+
121
+ // Check for unbalanced brackets
122
+ let bracketDepth = 0
123
+ let braceDepth = 0
124
+ for (const char of pattern) {
125
+ if (char === '[') bracketDepth++
126
+ if (char === ']') bracketDepth--
127
+ if (char === '{') braceDepth++
128
+ if (char === '}') braceDepth--
129
+ if (bracketDepth < 0 || braceDepth < 0) {
130
+ return { valid: false, error: 'Glob pattern has unbalanced brackets' }
131
+ }
132
+ }
133
+ if (bracketDepth !== 0 || braceDepth !== 0) {
134
+ return { valid: false, error: 'Glob pattern has unbalanced brackets' }
135
+ }
136
+
137
+ return { valid: true }
138
+ }
139
+
140
+ /**
141
+ * Build AnalyzerConfig from CLI flags (pure function)
142
+ */
143
+ export function buildAnalyzerConfig(
144
+ tsconfigPath: string,
145
+ flags: CliFlags,
146
+ ): AnalyzerConfig {
147
+ const config: AnalyzerConfig = {
148
+ tsconfigPath,
149
+ format: flags.format as 'console' | 'json' | 'summary',
150
+ quiet: flags.quiet,
151
+ verbose: flags.verbose,
152
+ }
153
+
154
+ if (flags.files !== undefined) {
155
+ config.filesGlob = flags.files
156
+ }
157
+ if (flags.minHealth !== undefined) {
158
+ config.minHealth = flags.minHealth
159
+ }
160
+ if (flags.minPurity !== undefined) {
161
+ config.minPurity = flags.minPurity
162
+ }
163
+ if (flags.minQuality !== undefined) {
164
+ config.minQuality = flags.minQuality
165
+ }
166
+ if (flags.output !== undefined) {
167
+ config.outputPath = flags.output
168
+ }
169
+ if (flags.dirDepth !== undefined) {
170
+ config.dirDepth = flags.dirDepth
171
+ }
172
+
173
+ return config
174
+ }
175
+
176
+ /**
177
+ * Build threshold config from CLI flags (pure function)
178
+ */
179
+ export function buildThresholdConfig(flags: CliFlags): {
180
+ minHealth?: number
181
+ minPurity?: number
182
+ minQuality?: number
183
+ } {
184
+ const config: {
185
+ minHealth?: number
186
+ minPurity?: number
187
+ minQuality?: number
188
+ } = {}
189
+
190
+ if (flags.minHealth !== undefined) {
191
+ config.minHealth = flags.minHealth
192
+ }
193
+ if (flags.minPurity !== undefined) {
194
+ config.minPurity = flags.minPurity
195
+ }
196
+ if (flags.minQuality !== undefined) {
197
+ config.minQuality = flags.minQuality
198
+ }
199
+
200
+ return config
201
+ }
package/src/cli.ts CHANGED
@@ -13,26 +13,90 @@
13
13
  */
14
14
 
15
15
  import { cli } from 'cleye'
16
- import { z } from 'zod'
17
16
  import chalk from 'chalk'
18
17
  import * as fs from 'node:fs'
19
18
  import * as path from 'node:path'
20
19
 
21
20
  import { analyze, checkThresholds } from './analyzer.js'
22
- import { stripJsonComments } from './extraction/extractor.js'
21
+ import { validateTsconfigContent } from './extraction/extractor.js'
23
22
  import {
24
23
  printConsoleReport,
25
24
  generateSummaryLine,
26
25
  } from './reporting/report-console.js'
27
26
  import { generateJsonReport, writeJsonReport } from './reporting/report-json.js'
28
- import type { AnalyzerConfig } from './types.js'
27
+ import type { ProjectScore } from './types.js'
28
+ import {
29
+ validateCliFlags,
30
+ validateGlobPattern,
31
+ buildAnalyzerConfig,
32
+ buildThresholdConfig,
33
+ type CliFlags,
34
+ type ValidationResult,
35
+ } from './cli-utils.js'
29
36
 
30
37
  const EXIT_SUCCESS = 0
31
38
  const EXIT_THRESHOLD_FAILED = 1
32
39
  const EXIT_CONFIG_ERROR = 2
33
40
  const EXIT_ANALYSIS_ERROR = 3
34
41
 
35
- const ThresholdSchema = z.number().min(0).max(100)
42
+ /**
43
+ * Handle analysis output based on flags (shell function - performs I/O)
44
+ */
45
+ function handleAnalysisOutput(score: ProjectScore, flags: CliFlags): void {
46
+ if (!flags.quiet) {
47
+ if (flags.json || flags.format === 'json') {
48
+ const jsonOptions: { pretty: boolean; dirDepth?: number } = {
49
+ pretty: true,
50
+ }
51
+ if (flags.dirDepth !== undefined) {
52
+ jsonOptions.dirDepth = flags.dirDepth
53
+ }
54
+ console.log(generateJsonReport(score, jsonOptions))
55
+ } else if (flags.format === 'summary') {
56
+ console.log(generateSummaryLine(score))
57
+ } else {
58
+ const reportOptions: { verbose?: boolean; dirDepth?: number } = {
59
+ verbose: flags.verbose,
60
+ }
61
+ if (flags.dirDepth !== undefined) {
62
+ reportOptions.dirDepth = flags.dirDepth
63
+ }
64
+ printConsoleReport(score, reportOptions)
65
+ }
66
+ }
67
+
68
+ // Write JSON to file if requested
69
+ if (flags.output) {
70
+ const outputOptions: { pretty: boolean; dirDepth?: number } = {
71
+ pretty: true,
72
+ }
73
+ if (flags.dirDepth !== undefined) {
74
+ outputOptions.dirDepth = flags.dirDepth
75
+ }
76
+ writeJsonReport(score, flags.output, outputOptions)
77
+ if (!flags.quiet && flags.format !== 'json') {
78
+ console.log(chalk.green(`Report written to: ${flags.output}`))
79
+ }
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Ensure output directory exists (shell function - performs I/O)
85
+ */
86
+ function ensureOutputDirectory(outputPath: string): ValidationResult {
87
+ const outputDir = path.dirname(path.resolve(outputPath))
88
+ if (!fs.existsSync(outputDir)) {
89
+ try {
90
+ fs.mkdirSync(outputDir, { recursive: true })
91
+ } catch {
92
+ return {
93
+ valid: false,
94
+ error: `Cannot create output directory: ${outputDir}`,
95
+ }
96
+ }
97
+ }
98
+ return { valid: true }
99
+ }
36
100
 
37
101
  const argv = cli({
38
102
  name: 'fcis',
@@ -83,6 +147,11 @@ const argv = cli({
83
147
  description: 'Show per-file scores and all classified functions',
84
148
  default: false,
85
149
  },
150
+ dirDepth: {
151
+ type: Number,
152
+ description:
153
+ 'Roll up directory metrics to depth N (e.g., 1 for top-level)',
154
+ },
86
155
  },
87
156
  parameters: ['<tsconfig>'],
88
157
  help: {
@@ -102,13 +171,14 @@ async function main(): Promise<void> {
102
171
 
103
172
  const tsconfigPath = args.tsconfig
104
173
 
105
- // Validate tsconfig exists
174
+ // Validate tsconfig path provided
106
175
  if (!tsconfigPath) {
107
176
  console.error(chalk.red('Error: tsconfig path is required'))
108
177
  console.error('Usage: fcis <tsconfig> [options]')
109
178
  process.exit(EXIT_CONFIG_ERROR)
110
179
  }
111
180
 
181
+ // Validate tsconfig exists
112
182
  const absoluteTsconfigPath = path.resolve(tsconfigPath)
113
183
  if (!fs.existsSync(absoluteTsconfigPath)) {
114
184
  console.error(
@@ -117,71 +187,39 @@ async function main(): Promise<void> {
117
187
  process.exit(EXIT_CONFIG_ERROR)
118
188
  }
119
189
 
120
- // Validate tsconfig is valid JSON (strip comments first since TypeScript allows them)
121
- try {
122
- const content = fs.readFileSync(absoluteTsconfigPath, 'utf-8')
123
- const strippedContent = stripJsonComments(content)
124
- JSON.parse(strippedContent)
125
- } catch (e) {
190
+ // Validate tsconfig content
191
+ const tsconfigContent = fs.readFileSync(absoluteTsconfigPath, 'utf-8')
192
+ const tsconfigValidation = validateTsconfigContent(tsconfigContent)
193
+ if (!tsconfigValidation.valid) {
126
194
  console.error(
127
195
  chalk.red(`Error: Invalid tsconfig.json at ${absoluteTsconfigPath}`),
128
196
  )
129
- console.error(e instanceof Error ? e.message : String(e))
197
+ console.error(tsconfigValidation.error)
130
198
  process.exit(EXIT_CONFIG_ERROR)
131
199
  }
132
200
 
133
- // Validate threshold values
134
- if (flags.minHealth !== undefined) {
135
- const result = ThresholdSchema.safeParse(flags.minHealth)
136
- if (!result.success) {
137
- console.error(
138
- chalk.red('Error: --min-health must be a number between 0 and 100'),
139
- )
140
- process.exit(EXIT_CONFIG_ERROR)
141
- }
142
- }
143
-
144
- if (flags.minPurity !== undefined) {
145
- const result = ThresholdSchema.safeParse(flags.minPurity)
146
- if (!result.success) {
147
- console.error(
148
- chalk.red('Error: --min-purity must be a number between 0 and 100'),
149
- )
150
- process.exit(EXIT_CONFIG_ERROR)
151
- }
201
+ // Validate CLI flags
202
+ const flagsValidation = validateCliFlags(flags)
203
+ if (!flagsValidation.valid) {
204
+ console.error(chalk.red(`Error: ${flagsValidation.error}`))
205
+ process.exit(EXIT_CONFIG_ERROR)
152
206
  }
153
207
 
154
- if (flags.minQuality !== undefined) {
155
- const result = ThresholdSchema.safeParse(flags.minQuality)
156
- if (!result.success) {
157
- console.error(
158
- chalk.red('Error: --min-quality must be a number between 0 and 100'),
159
- )
208
+ // Validate glob pattern if provided
209
+ if (flags.files !== undefined) {
210
+ const globValidation = validateGlobPattern(flags.files)
211
+ if (!globValidation.valid) {
212
+ console.error(chalk.red(`Error: ${globValidation.error}`))
160
213
  process.exit(EXIT_CONFIG_ERROR)
161
214
  }
162
215
  }
163
216
 
164
- // Validate format
165
- const validFormats = ['console', 'json', 'summary']
166
- if (!validFormats.includes(flags.format)) {
167
- console.error(
168
- chalk.red(`Error: --format must be one of: ${validFormats.join(', ')}`),
169
- )
170
- process.exit(EXIT_CONFIG_ERROR)
171
- }
172
-
173
217
  // Validate output path if provided
174
218
  if (flags.output) {
175
- const outputDir = path.dirname(path.resolve(flags.output))
176
- if (!fs.existsSync(outputDir)) {
177
- try {
178
- fs.mkdirSync(outputDir, { recursive: true })
179
- } catch (e) {
180
- console.error(
181
- chalk.red(`Error: Cannot create output directory: ${outputDir}`),
182
- )
183
- process.exit(EXIT_CONFIG_ERROR)
184
- }
219
+ const outputValidation = ensureOutputDirectory(flags.output)
220
+ if (!outputValidation.valid) {
221
+ console.error(chalk.red(`Error: ${outputValidation.error}`))
222
+ process.exit(EXIT_CONFIG_ERROR)
185
223
  }
186
224
  }
187
225
 
@@ -191,69 +229,14 @@ async function main(): Promise<void> {
191
229
  console.log(chalk.gray(`Analyzing ${process.cwd()}...`))
192
230
  }
193
231
 
194
- // Build config with proper undefined handling
195
- const config: AnalyzerConfig = {
196
- tsconfigPath: absoluteTsconfigPath,
197
- format: flags.format as 'console' | 'json' | 'summary',
198
- quiet: flags.quiet,
199
- verbose: flags.verbose,
200
- }
201
-
202
- // Only add optional properties if they are defined
203
- if (flags.files !== undefined) {
204
- config.filesGlob = flags.files
205
- }
206
- if (flags.minHealth !== undefined) {
207
- config.minHealth = flags.minHealth
208
- }
209
- if (flags.minPurity !== undefined) {
210
- config.minPurity = flags.minPurity
211
- }
212
- if (flags.minQuality !== undefined) {
213
- config.minQuality = flags.minQuality
214
- }
215
- if (flags.output !== undefined) {
216
- config.outputPath = flags.output
217
- }
218
-
232
+ const config = buildAnalyzerConfig(absoluteTsconfigPath, flags)
219
233
  const score = await analyze(config)
220
234
 
221
- // Output results based on format
222
- if (!flags.quiet) {
223
- if (flags.json || flags.format === 'json') {
224
- console.log(generateJsonReport(score, { pretty: true }))
225
- } else if (flags.format === 'summary') {
226
- console.log(generateSummaryLine(score))
227
- } else {
228
- printConsoleReport(score, { verbose: flags.verbose })
229
- }
230
- }
231
-
232
- // Write JSON to file if requested
233
- if (flags.output) {
234
- writeJsonReport(score, flags.output, { pretty: true })
235
- if (!flags.quiet && flags.format !== 'json') {
236
- console.log(chalk.green(`Report written to: ${flags.output}`))
237
- }
238
- }
239
-
240
- // Check thresholds - build object only with defined values
241
- const thresholdConfig: {
242
- minHealth?: number
243
- minPurity?: number
244
- minQuality?: number
245
- } = {}
246
-
247
- if (flags.minHealth !== undefined) {
248
- thresholdConfig.minHealth = flags.minHealth
249
- }
250
- if (flags.minPurity !== undefined) {
251
- thresholdConfig.minPurity = flags.minPurity
252
- }
253
- if (flags.minQuality !== undefined) {
254
- thresholdConfig.minQuality = flags.minQuality
255
- }
235
+ // Output results
236
+ handleAnalysisOutput(score, flags)
256
237
 
238
+ // Check thresholds
239
+ const thresholdConfig = buildThresholdConfig(flags)
257
240
  const thresholdResult = checkThresholds(score, thresholdConfig)
258
241
 
259
242
  if (!thresholdResult.passed) {
@@ -274,7 +257,6 @@ async function main(): Promise<void> {
274
257
  console.error(error instanceof Error ? error.message : String(error))
275
258
  }
276
259
 
277
- // Check if it's a "no files" error
278
260
  if (error instanceof Error && error.message.includes('No source files')) {
279
261
  process.exit(EXIT_ANALYSIS_ERROR)
280
262
  }