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.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fcis",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Functional Core, Imperative Shell analyzer for TypeScript codebases",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -22,6 +22,8 @@
22
22
  "dependencies": {
23
23
  "chalk": "^5.3.0",
24
24
  "cleye": "^1.3.2",
25
+ "fcis": "0.2.1",
26
+ "jsonc-parser": "^3.3.1",
25
27
  "ts-morph": "^24.0.0",
26
28
  "ts-pattern": "^5.6.0",
27
29
  "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
@@ -12,31 +12,100 @@
12
12
  * - 3: Analysis error (zero files could be analyzed)
13
13
  */
14
14
 
15
+ import { createRequire } from 'node:module'
15
16
  import { cli } from 'cleye'
16
- import { z } from 'zod'
17
17
  import chalk from 'chalk'
18
18
  import * as fs from 'node:fs'
19
19
  import * as path from 'node:path'
20
20
 
21
21
  import { analyze, checkThresholds } from './analyzer.js'
22
- import { stripJsonComments } from './extraction/extractor.js'
22
+ import { validateTsconfigContent } from './extraction/extractor.js'
23
23
  import {
24
24
  printConsoleReport,
25
25
  generateSummaryLine,
26
26
  } from './reporting/report-console.js'
27
27
  import { generateJsonReport, writeJsonReport } from './reporting/report-json.js'
28
- import type { AnalyzerConfig } from './types.js'
28
+ import type { ProjectScore } from './types.js'
29
+ import {
30
+ validateCliFlags,
31
+ validateGlobPattern,
32
+ buildAnalyzerConfig,
33
+ buildThresholdConfig,
34
+ type CliFlags,
35
+ type ValidationResult,
36
+ } from './cli-utils.js'
29
37
 
30
38
  const EXIT_SUCCESS = 0
31
39
  const EXIT_THRESHOLD_FAILED = 1
32
40
  const EXIT_CONFIG_ERROR = 2
33
41
  const EXIT_ANALYSIS_ERROR = 3
34
42
 
35
- const ThresholdSchema = z.number().min(0).max(100)
43
+ // Read version from package.json to maintain single source of truth
44
+ const require = createRequire(import.meta.url)
45
+ const pkg = require('../package.json') as { version: string }
46
+
47
+ /**
48
+ * Handle analysis output based on flags (shell function - performs I/O)
49
+ */
50
+ function handleAnalysisOutput(score: ProjectScore, flags: CliFlags): void {
51
+ if (!flags.quiet) {
52
+ if (flags.json || flags.format === 'json') {
53
+ const jsonOptions: { pretty: boolean; dirDepth?: number } = {
54
+ pretty: true,
55
+ }
56
+ if (flags.dirDepth !== undefined) {
57
+ jsonOptions.dirDepth = flags.dirDepth
58
+ }
59
+ console.log(generateJsonReport(score, jsonOptions))
60
+ } else if (flags.format === 'summary') {
61
+ console.log(generateSummaryLine(score))
62
+ } else {
63
+ const reportOptions: { verbose?: boolean; dirDepth?: number } = {
64
+ verbose: flags.verbose,
65
+ }
66
+ if (flags.dirDepth !== undefined) {
67
+ reportOptions.dirDepth = flags.dirDepth
68
+ }
69
+ printConsoleReport(score, reportOptions)
70
+ }
71
+ }
72
+
73
+ // Write JSON to file if requested
74
+ if (flags.output) {
75
+ const outputOptions: { pretty: boolean; dirDepth?: number } = {
76
+ pretty: true,
77
+ }
78
+ if (flags.dirDepth !== undefined) {
79
+ outputOptions.dirDepth = flags.dirDepth
80
+ }
81
+ writeJsonReport(score, flags.output, outputOptions)
82
+ if (!flags.quiet && flags.format !== 'json') {
83
+ console.log(chalk.green(`Report written to: ${flags.output}`))
84
+ }
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Ensure output directory exists (shell function - performs I/O)
90
+ */
91
+ function ensureOutputDirectory(outputPath: string): ValidationResult {
92
+ const outputDir = path.dirname(path.resolve(outputPath))
93
+ if (!fs.existsSync(outputDir)) {
94
+ try {
95
+ fs.mkdirSync(outputDir, { recursive: true })
96
+ } catch {
97
+ return {
98
+ valid: false,
99
+ error: `Cannot create output directory: ${outputDir}`,
100
+ }
101
+ }
102
+ }
103
+ return { valid: true }
104
+ }
36
105
 
37
106
  const argv = cli({
38
107
  name: 'fcis',
39
- version: '0.1.0',
108
+ version: pkg.version,
40
109
  flags: {
41
110
  json: {
42
111
  type: Boolean,
@@ -83,6 +152,11 @@ const argv = cli({
83
152
  description: 'Show per-file scores and all classified functions',
84
153
  default: false,
85
154
  },
155
+ dirDepth: {
156
+ type: Number,
157
+ description:
158
+ 'Roll up directory metrics to depth N (e.g., 1 for top-level)',
159
+ },
86
160
  },
87
161
  parameters: ['<tsconfig>'],
88
162
  help: {
@@ -102,13 +176,14 @@ async function main(): Promise<void> {
102
176
 
103
177
  const tsconfigPath = args.tsconfig
104
178
 
105
- // Validate tsconfig exists
179
+ // Validate tsconfig path provided
106
180
  if (!tsconfigPath) {
107
181
  console.error(chalk.red('Error: tsconfig path is required'))
108
182
  console.error('Usage: fcis <tsconfig> [options]')
109
183
  process.exit(EXIT_CONFIG_ERROR)
110
184
  }
111
185
 
186
+ // Validate tsconfig exists
112
187
  const absoluteTsconfigPath = path.resolve(tsconfigPath)
113
188
  if (!fs.existsSync(absoluteTsconfigPath)) {
114
189
  console.error(
@@ -117,71 +192,39 @@ async function main(): Promise<void> {
117
192
  process.exit(EXIT_CONFIG_ERROR)
118
193
  }
119
194
 
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) {
195
+ // Validate tsconfig content
196
+ const tsconfigContent = fs.readFileSync(absoluteTsconfigPath, 'utf-8')
197
+ const tsconfigValidation = validateTsconfigContent(tsconfigContent)
198
+ if (!tsconfigValidation.valid) {
126
199
  console.error(
127
200
  chalk.red(`Error: Invalid tsconfig.json at ${absoluteTsconfigPath}`),
128
201
  )
129
- console.error(e instanceof Error ? e.message : String(e))
202
+ console.error(tsconfigValidation.error)
130
203
  process.exit(EXIT_CONFIG_ERROR)
131
204
  }
132
205
 
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
- }
206
+ // Validate CLI flags
207
+ const flagsValidation = validateCliFlags(flags)
208
+ if (!flagsValidation.valid) {
209
+ console.error(chalk.red(`Error: ${flagsValidation.error}`))
210
+ process.exit(EXIT_CONFIG_ERROR)
152
211
  }
153
212
 
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
- )
213
+ // Validate glob pattern if provided
214
+ if (flags.files !== undefined) {
215
+ const globValidation = validateGlobPattern(flags.files)
216
+ if (!globValidation.valid) {
217
+ console.error(chalk.red(`Error: ${globValidation.error}`))
160
218
  process.exit(EXIT_CONFIG_ERROR)
161
219
  }
162
220
  }
163
221
 
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
222
  // Validate output path if provided
174
223
  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
- }
224
+ const outputValidation = ensureOutputDirectory(flags.output)
225
+ if (!outputValidation.valid) {
226
+ console.error(chalk.red(`Error: ${outputValidation.error}`))
227
+ process.exit(EXIT_CONFIG_ERROR)
185
228
  }
186
229
  }
187
230
 
@@ -191,69 +234,14 @@ async function main(): Promise<void> {
191
234
  console.log(chalk.gray(`Analyzing ${process.cwd()}...`))
192
235
  }
193
236
 
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
-
237
+ const config = buildAnalyzerConfig(absoluteTsconfigPath, flags)
219
238
  const score = await analyze(config)
220
239
 
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
- }
240
+ // Output results
241
+ handleAnalysisOutput(score, flags)
256
242
 
243
+ // Check thresholds
244
+ const thresholdConfig = buildThresholdConfig(flags)
257
245
  const thresholdResult = checkThresholds(score, thresholdConfig)
258
246
 
259
247
  if (!thresholdResult.passed) {
@@ -274,7 +262,6 @@ async function main(): Promise<void> {
274
262
  console.error(error instanceof Error ? error.message : String(error))
275
263
  }
276
264
 
277
- // Check if it's a "no files" error
278
265
  if (error instanceof Error && error.message.includes('No source files')) {
279
266
  process.exit(EXIT_ANALYSIS_ERROR)
280
267
  }