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/src/types.ts CHANGED
@@ -31,7 +31,7 @@ export type ExtractedFunction = {
31
31
  bodyLineCount: number
32
32
  statementCount: number
33
33
  hasConditionals: boolean
34
- parentContext: string | null // e.g. class name, variable name
34
+ parentContext: string | null // e.g. class name, variable name, or HOF method name
35
35
  callSites: CallSite[] // enriched call expressions with position and await context
36
36
  hasAwait: boolean // convenience flag: true if any callSite.isAwaited
37
37
  propertyAccessChains: string[] // e.g. ["process.env.NODE_ENV", "db.user"]
@@ -42,6 +42,11 @@ export type ExtractedFunction = {
42
42
  | 'function-expression'
43
43
  | 'getter'
44
44
  | 'setter'
45
+ // Compositional scoring fields (Plan 007)
46
+ // Reference to enclosing function for inline callbacks; null if at module scope or not a callback
47
+ enclosingFunctionStartLine: number | null
48
+ // True if this is an inline callback passed to a known HOF (map, filter, forEach, etc.)
49
+ isInlineCallback: boolean
45
50
  }
46
51
 
47
52
  /**
@@ -65,7 +70,6 @@ export type MarkerType =
65
70
  | 'database-call'
66
71
  | 'network-fetch'
67
72
  | 'network-http'
68
- | 'fs-import'
69
73
  | 'fs-call'
70
74
  | 'env-access'
71
75
  | 'console-log'
@@ -187,6 +191,8 @@ export type ProjectScore = {
187
191
  filesGlob?: string // the glob pattern used for --files
188
192
  // Errors encountered during analysis
189
193
  errors?: AnalysisError[]
194
+ // Rolled-up directories (when --dir-depth is used)
195
+ rolledUpDirectories?: DirectoryScore[]
190
196
  }
191
197
 
192
198
  /**
@@ -210,6 +216,7 @@ export type AnalyzerConfig = {
210
216
  outputPath?: string
211
217
  quiet?: boolean
212
218
  verbose?: boolean
219
+ dirDepth?: number
213
220
  }
214
221
 
215
222
  /**
@@ -155,7 +155,6 @@ describe('classifyFunction', () => {
155
155
  'database-call',
156
156
  'network-fetch',
157
157
  'network-http',
158
- 'fs-import',
159
158
  'fs-call',
160
159
  'env-access',
161
160
  'console-log',
@@ -0,0 +1,356 @@
1
+ /**
2
+ * CLI Tests
3
+ *
4
+ * Tests for the extracted pure functions from the CLI module.
5
+ */
6
+
7
+ import { describe, it, expect } from 'vitest'
8
+
9
+ import {
10
+ validateCliFlags,
11
+ validateGlobPattern,
12
+ buildAnalyzerConfig,
13
+ buildThresholdConfig,
14
+ type CliFlags,
15
+ } from '../src/cli-utils.js'
16
+
17
+ /**
18
+ * Helper to create default CLI flags
19
+ */
20
+ function createDefaultFlags(overrides: Partial<CliFlags> = {}): CliFlags {
21
+ return {
22
+ json: false,
23
+ output: undefined,
24
+ minHealth: undefined,
25
+ minPurity: undefined,
26
+ minQuality: undefined,
27
+ files: undefined,
28
+ format: 'console',
29
+ quiet: false,
30
+ verbose: false,
31
+ dirDepth: undefined,
32
+ version: undefined,
33
+ help: undefined,
34
+ ...overrides,
35
+ }
36
+ }
37
+
38
+ describe('validateCliFlags', () => {
39
+ describe('threshold validation', () => {
40
+ it('should accept valid minHealth', () => {
41
+ const flags = createDefaultFlags({ minHealth: 70 })
42
+ const result = validateCliFlags(flags)
43
+ expect(result).toEqual({ valid: true })
44
+ })
45
+
46
+ it('should accept minHealth of 0', () => {
47
+ const flags = createDefaultFlags({ minHealth: 0 })
48
+ const result = validateCliFlags(flags)
49
+ expect(result).toEqual({ valid: true })
50
+ })
51
+
52
+ it('should accept minHealth of 100', () => {
53
+ const flags = createDefaultFlags({ minHealth: 100 })
54
+ const result = validateCliFlags(flags)
55
+ expect(result).toEqual({ valid: true })
56
+ })
57
+
58
+ it('should reject minHealth > 100', () => {
59
+ const flags = createDefaultFlags({ minHealth: 101 })
60
+ const result = validateCliFlags(flags)
61
+ expect(result.valid).toBe(false)
62
+ if (!result.valid) {
63
+ expect(result.error).toContain('--min-health')
64
+ }
65
+ })
66
+
67
+ it('should reject minHealth < 0', () => {
68
+ const flags = createDefaultFlags({ minHealth: -1 })
69
+ const result = validateCliFlags(flags)
70
+ expect(result.valid).toBe(false)
71
+ })
72
+
73
+ it('should reject minPurity > 100', () => {
74
+ const flags = createDefaultFlags({ minPurity: 150 })
75
+ const result = validateCliFlags(flags)
76
+ expect(result.valid).toBe(false)
77
+ if (!result.valid) {
78
+ expect(result.error).toContain('--min-purity')
79
+ }
80
+ })
81
+
82
+ it('should reject minQuality > 100', () => {
83
+ const flags = createDefaultFlags({ minQuality: 200 })
84
+ const result = validateCliFlags(flags)
85
+ expect(result.valid).toBe(false)
86
+ if (!result.valid) {
87
+ expect(result.error).toContain('--min-quality')
88
+ }
89
+ })
90
+
91
+ it('should accept all thresholds at once', () => {
92
+ const flags = createDefaultFlags({
93
+ minHealth: 70,
94
+ minPurity: 50,
95
+ minQuality: 60,
96
+ })
97
+ const result = validateCliFlags(flags)
98
+ expect(result).toEqual({ valid: true })
99
+ })
100
+ })
101
+
102
+ describe('format validation', () => {
103
+ it('should accept console format', () => {
104
+ const flags = createDefaultFlags({ format: 'console' })
105
+ const result = validateCliFlags(flags)
106
+ expect(result).toEqual({ valid: true })
107
+ })
108
+
109
+ it('should accept json format', () => {
110
+ const flags = createDefaultFlags({ format: 'json' })
111
+ const result = validateCliFlags(flags)
112
+ expect(result).toEqual({ valid: true })
113
+ })
114
+
115
+ it('should accept summary format', () => {
116
+ const flags = createDefaultFlags({ format: 'summary' })
117
+ const result = validateCliFlags(flags)
118
+ expect(result).toEqual({ valid: true })
119
+ })
120
+
121
+ it('should reject invalid format', () => {
122
+ const flags = createDefaultFlags({ format: 'xml' })
123
+ const result = validateCliFlags(flags)
124
+ expect(result.valid).toBe(false)
125
+ if (!result.valid) {
126
+ expect(result.error).toContain('--format')
127
+ }
128
+ })
129
+ })
130
+
131
+ describe('--dir-depth validation', () => {
132
+ it('should accept positive integer', () => {
133
+ const flags = createDefaultFlags({ dirDepth: 2 })
134
+ const result = validateCliFlags(flags)
135
+ expect(result).toEqual({ valid: true })
136
+ })
137
+
138
+ it('should accept depth of 1', () => {
139
+ const flags = createDefaultFlags({ dirDepth: 1 })
140
+ const result = validateCliFlags(flags)
141
+ expect(result).toEqual({ valid: true })
142
+ })
143
+
144
+ it('should reject zero', () => {
145
+ const flags = createDefaultFlags({ dirDepth: 0 })
146
+ const result = validateCliFlags(flags)
147
+ expect(result.valid).toBe(false)
148
+ if (!result.valid) {
149
+ expect(result.error).toContain('--dir-depth')
150
+ }
151
+ })
152
+
153
+ it('should reject negative numbers', () => {
154
+ const flags = createDefaultFlags({ dirDepth: -1 })
155
+ const result = validateCliFlags(flags)
156
+ expect(result.valid).toBe(false)
157
+ if (!result.valid) {
158
+ expect(result.error).toContain('--dir-depth')
159
+ }
160
+ })
161
+
162
+ it('should reject non-integers', () => {
163
+ const flags = createDefaultFlags({ dirDepth: 1.5 })
164
+ const result = validateCliFlags(flags)
165
+ expect(result.valid).toBe(false)
166
+ if (!result.valid) {
167
+ expect(result.error).toContain('--dir-depth')
168
+ }
169
+ })
170
+
171
+ it('should accept undefined (flag not provided)', () => {
172
+ const flags = createDefaultFlags({ dirDepth: undefined })
173
+ const result = validateCliFlags(flags)
174
+ expect(result).toEqual({ valid: true })
175
+ })
176
+ })
177
+ })
178
+
179
+ describe('validateGlobPattern', () => {
180
+ describe('valid patterns', () => {
181
+ it('should accept simple glob', () => {
182
+ const result = validateGlobPattern('src/**/*.ts')
183
+ expect(result).toEqual({ valid: true })
184
+ })
185
+
186
+ it('should accept pattern with brackets', () => {
187
+ const result = validateGlobPattern('src/[abc]/*.ts')
188
+ expect(result).toEqual({ valid: true })
189
+ })
190
+
191
+ it('should accept pattern with braces', () => {
192
+ const result = validateGlobPattern('src/{a,b,c}/*.ts')
193
+ expect(result).toEqual({ valid: true })
194
+ })
195
+
196
+ it('should accept relative path', () => {
197
+ const result = validateGlobPattern('./src/**/*.ts')
198
+ expect(result).toEqual({ valid: true })
199
+ })
200
+ })
201
+
202
+ describe('invalid patterns', () => {
203
+ it('should reject empty pattern', () => {
204
+ const result = validateGlobPattern('')
205
+ expect(result.valid).toBe(false)
206
+ if (!result.valid) {
207
+ expect(result.error).toContain('empty')
208
+ }
209
+ })
210
+
211
+ it('should reject whitespace-only pattern', () => {
212
+ const result = validateGlobPattern(' ')
213
+ expect(result.valid).toBe(false)
214
+ })
215
+
216
+ it('should reject absolute path', () => {
217
+ const result = validateGlobPattern('/Users/test/src/**/*.ts')
218
+ expect(result.valid).toBe(false)
219
+ if (!result.valid) {
220
+ expect(result.error).toContain('absolute')
221
+ }
222
+ })
223
+
224
+ it('should reject unbalanced opening bracket', () => {
225
+ const result = validateGlobPattern('src/[incomplete/*.ts')
226
+ expect(result.valid).toBe(false)
227
+ if (!result.valid) {
228
+ expect(result.error).toContain('unbalanced')
229
+ }
230
+ })
231
+
232
+ it('should reject unbalanced closing bracket', () => {
233
+ const result = validateGlobPattern('src/incomplete]/*.ts')
234
+ expect(result.valid).toBe(false)
235
+ if (!result.valid) {
236
+ expect(result.error).toContain('unbalanced')
237
+ }
238
+ })
239
+
240
+ it('should reject unbalanced opening brace', () => {
241
+ const result = validateGlobPattern('src/{a,b/*.ts')
242
+ expect(result.valid).toBe(false)
243
+ if (!result.valid) {
244
+ expect(result.error).toContain('unbalanced')
245
+ }
246
+ })
247
+
248
+ it('should reject unbalanced closing brace', () => {
249
+ const result = validateGlobPattern('src/a,b}/*.ts')
250
+ expect(result.valid).toBe(false)
251
+ if (!result.valid) {
252
+ expect(result.error).toContain('unbalanced')
253
+ }
254
+ })
255
+ })
256
+ })
257
+
258
+ describe('buildAnalyzerConfig', () => {
259
+ it('should build minimal config', () => {
260
+ const flags = createDefaultFlags()
261
+ const config = buildAnalyzerConfig('/path/to/tsconfig.json', flags)
262
+
263
+ expect(config.tsconfigPath).toBe('/path/to/tsconfig.json')
264
+ expect(config.format).toBe('console')
265
+ expect(config.quiet).toBe(false)
266
+ expect(config.verbose).toBe(false)
267
+ expect(config.filesGlob).toBeUndefined()
268
+ expect(config.minHealth).toBeUndefined()
269
+ expect(config.dirDepth).toBeUndefined()
270
+ })
271
+
272
+ it('should include optional properties when provided', () => {
273
+ const flags = createDefaultFlags({
274
+ files: 'src/**/*.ts',
275
+ minHealth: 70,
276
+ minPurity: 50,
277
+ minQuality: 60,
278
+ output: 'report.json',
279
+ format: 'json',
280
+ quiet: true,
281
+ verbose: true,
282
+ dirDepth: 2,
283
+ })
284
+ const config = buildAnalyzerConfig('/path/to/tsconfig.json', flags)
285
+
286
+ expect(config.tsconfigPath).toBe('/path/to/tsconfig.json')
287
+ expect(config.filesGlob).toBe('src/**/*.ts')
288
+ expect(config.minHealth).toBe(70)
289
+ expect(config.minPurity).toBe(50)
290
+ expect(config.minQuality).toBe(60)
291
+ expect(config.outputPath).toBe('report.json')
292
+ expect(config.format).toBe('json')
293
+ expect(config.quiet).toBe(true)
294
+ expect(config.verbose).toBe(true)
295
+ expect(config.dirDepth).toBe(2)
296
+ })
297
+
298
+ it('should include dirDepth when provided', () => {
299
+ const flags = createDefaultFlags({ dirDepth: 1 })
300
+ const config = buildAnalyzerConfig('/path/to/tsconfig.json', flags)
301
+
302
+ expect(config.dirDepth).toBe(1)
303
+ })
304
+
305
+ it('should not include dirDepth when undefined', () => {
306
+ const flags = createDefaultFlags({ dirDepth: undefined })
307
+ const config = buildAnalyzerConfig('/path/to/tsconfig.json', flags)
308
+
309
+ expect(config.dirDepth).toBeUndefined()
310
+ })
311
+ })
312
+
313
+ describe('buildThresholdConfig', () => {
314
+ it('should return empty object when no thresholds set', () => {
315
+ const flags = createDefaultFlags()
316
+ const config = buildThresholdConfig(flags)
317
+
318
+ expect(config).toEqual({})
319
+ })
320
+
321
+ it('should include minHealth when set', () => {
322
+ const flags = createDefaultFlags({ minHealth: 70 })
323
+ const config = buildThresholdConfig(flags)
324
+
325
+ expect(config).toEqual({ minHealth: 70 })
326
+ })
327
+
328
+ it('should include minPurity when set', () => {
329
+ const flags = createDefaultFlags({ minPurity: 50 })
330
+ const config = buildThresholdConfig(flags)
331
+
332
+ expect(config).toEqual({ minPurity: 50 })
333
+ })
334
+
335
+ it('should include minQuality when set', () => {
336
+ const flags = createDefaultFlags({ minQuality: 60 })
337
+ const config = buildThresholdConfig(flags)
338
+
339
+ expect(config).toEqual({ minQuality: 60 })
340
+ })
341
+
342
+ it('should include all thresholds when all set', () => {
343
+ const flags = createDefaultFlags({
344
+ minHealth: 70,
345
+ minPurity: 50,
346
+ minQuality: 60,
347
+ })
348
+ const config = buildThresholdConfig(flags)
349
+
350
+ expect(config).toEqual({
351
+ minHealth: 70,
352
+ minPurity: 50,
353
+ minQuality: 60,
354
+ })
355
+ })
356
+ })
@@ -271,9 +271,7 @@ describe('detectMarkers', () => {
271
271
 
272
272
  const markers = detectMarkers(fn, context)
273
273
 
274
- expect(
275
- markers.filter(m => m.type === 'fs-import' || m.type === 'fs-call'),
276
- ).toHaveLength(0)
274
+ expect(markers.filter(m => m.type === 'fs-call')).toHaveLength(0)
277
275
  })
278
276
 
279
277
  it('should detect fs.readFile calls', () => {
@@ -10,7 +10,10 @@ import * as fs from 'node:fs'
10
10
  import * as path from 'node:path'
11
11
  import * as os from 'node:os'
12
12
 
13
- import { loadProject } from '../src/extraction/extractor.js'
13
+ import {
14
+ loadProject,
15
+ validateTsconfigContent,
16
+ } from '../src/extraction/extractor.js'
14
17
 
15
18
  describe('loadProject', () => {
16
19
  let tempDir: string
@@ -126,6 +129,97 @@ describe('loadProject', () => {
126
129
 
127
130
  expect(() => loadProject({ tsconfigPath })).not.toThrow()
128
131
  })
132
+
133
+ it('should parse tsconfig with trailing commas', () => {
134
+ const tsconfigContent = `{
135
+ "compilerOptions": {
136
+ "typeRoots": [
137
+ "./node_modules/@types",
138
+ ]
139
+ },
140
+ "include": [
141
+ "./src/**/*.ts",
142
+ ],
143
+ "exclude": [
144
+ "node_modules",
145
+ ],
146
+ }`
147
+
148
+ const tsconfigPath = path.join(tempDir, 'tsconfig.json')
149
+ fs.writeFileSync(tsconfigPath, tsconfigContent)
150
+
151
+ // Create a simple source file
152
+ fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export const foo = 1;')
153
+
154
+ expect(() => loadProject({ tsconfigPath })).not.toThrow()
155
+ })
156
+
157
+ it('should parse tsconfig with extends and trailing comma in array (like packages/chat)', () => {
158
+ // This matches the exact format of packages/chat/tsconfig.json
159
+ const tsconfigContent = `{
160
+ "extends": "@sai/tsconfig/pkg.json",
161
+ "compilerOptions": {
162
+ "typeRoots": [
163
+ "./node_modules/@types",
164
+ ]
165
+ },
166
+ "include": [
167
+ "./src/**/*.ts"
168
+ ],
169
+ "exclude": [
170
+ "node_modules"
171
+ ]
172
+ }`
173
+
174
+ const tsconfigPath = path.join(tempDir, 'tsconfig.json')
175
+ fs.writeFileSync(tsconfigPath, tsconfigContent)
176
+
177
+ // Create a simple source file
178
+ fs.writeFileSync(path.join(tempDir, 'index.ts'), 'export const foo = 1;')
179
+
180
+ expect(() => loadProject({ tsconfigPath })).not.toThrow()
181
+ })
182
+ })
183
+
184
+ describe('validateTsconfigContent', () => {
185
+ it('should return valid for correct JSON', () => {
186
+ const result = validateTsconfigContent('{ "compilerOptions": {} }')
187
+ expect(result).toEqual({ valid: true })
188
+ })
189
+
190
+ it('should return valid for JSONC with comments', () => {
191
+ const content = `{
192
+ // This is a comment
193
+ "compilerOptions": {
194
+ "target": "es2022" /* inline */
195
+ }
196
+ }`
197
+ const result = validateTsconfigContent(content)
198
+ expect(result).toEqual({ valid: true })
199
+ })
200
+
201
+ it('should return valid for JSONC with trailing commas', () => {
202
+ const content = `{
203
+ "compilerOptions": {
204
+ "lib": ["es2023",],
205
+ },
206
+ }`
207
+ const result = validateTsconfigContent(content)
208
+ expect(result).toEqual({ valid: true })
209
+ })
210
+
211
+ it('should return error for invalid JSON', () => {
212
+ const result = validateTsconfigContent('{ invalid }')
213
+ expect(result.valid).toBe(false)
214
+ if (!result.valid) {
215
+ expect(result.error).toContain('Parse error')
216
+ }
217
+ })
218
+
219
+ it('should return error for missing closing brace', () => {
220
+ const result = validateTsconfigContent('{ "a": 1')
221
+ expect(result.valid).toBe(false)
222
+ })
129
223
  })
130
224
 
131
225
  describe('error handling', () => {