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.
@@ -23,74 +23,6 @@ export type MarkerDefinition = {
23
23
  severity: 'high' | 'medium' | 'low'
24
24
  }
25
25
 
26
- /**
27
- * Database operations that indicate I/O
28
- */
29
- export const DATABASE_OPERATIONS = new Set([
30
- 'findFirst',
31
- 'findMany',
32
- 'findUnique',
33
- 'findUniqueOrThrow',
34
- 'findFirstOrThrow',
35
- 'create',
36
- 'createMany',
37
- 'update',
38
- 'updateMany',
39
- 'delete',
40
- 'deleteMany',
41
- 'upsert',
42
- 'aggregate',
43
- 'count',
44
- 'groupBy',
45
- '$transaction',
46
- '$queryRaw',
47
- '$executeRaw',
48
- ])
49
-
50
- /**
51
- * Database client prefixes
52
- */
53
- export const DATABASE_PREFIXES = ['db', 'prisma', 'ctx.db', 'ctx.prisma']
54
-
55
- /**
56
- * File system modules that indicate I/O
57
- */
58
- export const FS_MODULES = new Set([
59
- 'fs',
60
- 'node:fs',
61
- 'fs/promises',
62
- 'node:fs/promises',
63
- ])
64
-
65
- /**
66
- * HTTP client modules
67
- */
68
- export const HTTP_MODULES = new Set(['axios', 'node-fetch', 'got', 'ky'])
69
-
70
- /**
71
- * Console methods that indicate logging
72
- */
73
- export const CONSOLE_METHODS = new Set([
74
- 'log',
75
- 'error',
76
- 'warn',
77
- 'info',
78
- 'debug',
79
- 'trace',
80
- ])
81
-
82
- /**
83
- * Analytics/telemetry function patterns
84
- */
85
- export const TELEMETRY_PATTERNS = [
86
- /^track[A-Z]/,
87
- /^analytics\./,
88
- /^segment\./,
89
- /^posthog\./,
90
- /^mixpanel\./,
91
- /^amplitude\./,
92
- ]
93
-
94
26
  /**
95
27
  * Marker catalog - all recognized impurity markers
96
28
  */
@@ -119,12 +51,6 @@ export const MARKER_CATALOG: Record<MarkerType, MarkerDefinition> = {
119
51
  category: 'io',
120
52
  severity: 'high',
121
53
  },
122
- 'fs-import': {
123
- type: 'fs-import',
124
- description: 'Import from file system module',
125
- category: 'io',
126
- severity: 'medium',
127
- },
128
54
  'fs-call': {
129
55
  type: 'fs-call',
130
56
  description: 'File system operation call',
@@ -182,151 +108,3 @@ export function getMarkerDefinition(type: MarkerType): MarkerDefinition {
182
108
  export function getAllMarkerTypes(): MarkerType[] {
183
109
  return Object.keys(MARKER_CATALOG) as MarkerType[]
184
110
  }
185
-
186
- /**
187
- * Get markers by category
188
- */
189
- export function getMarkersByCategory(
190
- category: MarkerDefinition['category'],
191
- ): MarkerDefinition[] {
192
- return Object.values(MARKER_CATALOG).filter(m => m.category === category)
193
- }
194
-
195
- /**
196
- * Get markers by severity
197
- */
198
- export function getMarkersBySeverity(
199
- severity: MarkerDefinition['severity'],
200
- ): MarkerDefinition[] {
201
- return Object.values(MARKER_CATALOG).filter(m => m.severity === severity)
202
- }
203
-
204
- /**
205
- * Check if a call expression matches a database operation pattern
206
- * e.g., "db.user.findFirst", "prisma.user.create", "ctx.db.user.update"
207
- */
208
- export function isDatabaseCall(expression: string): boolean {
209
- // Check if expression starts with a known database prefix
210
- const lowerExpr = expression.toLowerCase()
211
-
212
- for (const prefix of DATABASE_PREFIXES) {
213
- if (lowerExpr.startsWith(prefix + '.')) {
214
- // Extract the operation (last part of the chain)
215
- const parts = expression.split('.')
216
- const operation = parts[parts.length - 1]
217
- if (operation && DATABASE_OPERATIONS.has(operation)) {
218
- return true
219
- }
220
- }
221
- }
222
-
223
- return false
224
- }
225
-
226
- /**
227
- * Check if a call expression is a fetch call
228
- */
229
- export function isFetchCall(expression: string): boolean {
230
- return expression === 'fetch' || expression.endsWith('.fetch')
231
- }
232
-
233
- /**
234
- * Check if a call expression is an HTTP client call
235
- */
236
- export function isHttpClientCall(
237
- expression: string,
238
- importedModules: Set<string>,
239
- ): boolean {
240
- // Check if axios or similar is imported
241
- for (const module of HTTP_MODULES) {
242
- if (importedModules.has(module)) {
243
- // Check for axios.get, axios.post, etc.
244
- if (
245
- expression.startsWith('axios.') ||
246
- expression === 'axios' ||
247
- expression.startsWith('got.') ||
248
- expression === 'got' ||
249
- expression.startsWith('ky.')
250
- ) {
251
- return true
252
- }
253
- }
254
- }
255
-
256
- return false
257
- }
258
-
259
- /**
260
- * Check if a call expression is a file system call
261
- */
262
- export function isFsCall(expression: string): boolean {
263
- return (
264
- expression.startsWith('fs.') ||
265
- expression.startsWith('fsPromises.') ||
266
- expression.startsWith('fsp.')
267
- )
268
- }
269
-
270
- /**
271
- * Check if a module specifier is a file system module
272
- */
273
- export function isFsModule(moduleSpecifier: string): boolean {
274
- return FS_MODULES.has(moduleSpecifier)
275
- }
276
-
277
- /**
278
- * Check if a property access is an environment variable access
279
- */
280
- export function isEnvAccess(propertyChain: string): boolean {
281
- return (
282
- propertyChain.startsWith('process.env.') || propertyChain === 'process.env'
283
- )
284
- }
285
-
286
- /**
287
- * Check if a call expression is a console log call
288
- */
289
- export function isConsoleCall(expression: string): boolean {
290
- if (!expression.startsWith('console.')) return false
291
-
292
- const method = expression.slice('console.'.length)
293
- return CONSOLE_METHODS.has(method)
294
- }
295
-
296
- /**
297
- * Check if a call expression is a logging call
298
- */
299
- export function isLoggingCall(expression: string): boolean {
300
- return (
301
- expression.startsWith('logger.') ||
302
- expression.startsWith('log.') ||
303
- expression === 'log' ||
304
- expression.startsWith('Logger.')
305
- )
306
- }
307
-
308
- /**
309
- * Check if a call expression is a telemetry/analytics call
310
- */
311
- export function isTelemetryCall(expression: string): boolean {
312
- for (const pattern of TELEMETRY_PATTERNS) {
313
- if (pattern.test(expression)) {
314
- return true
315
- }
316
- }
317
- return false
318
- }
319
-
320
- /**
321
- * Check if a call expression is a queue enqueue call
322
- */
323
- export function isQueueEnqueueCall(expression: string): boolean {
324
- return expression.endsWith('.enqueue') || expression.endsWith('.add')
325
- }
326
-
327
- /**
328
- * Check if a call expression is an event emit call
329
- */
330
- export function isEventEmitCall(expression: string): boolean {
331
- return expression.endsWith('.emit') || expression.endsWith('.dispatch')
332
- }
@@ -30,6 +30,42 @@ import {
30
30
 
31
31
  import type { CallSite, ExtractedFunction, FileImports } from '../types.js'
32
32
 
33
+ /**
34
+ * Known higher-order function method names that take inline callbacks.
35
+ * Callbacks passed to these methods are marked as inline callbacks
36
+ * and absorbed into their parent function's score.
37
+ *
38
+ * Explicitly EXCLUDED (not inline callbacks):
39
+ * - tRPC handlers: query, mutation, subscription
40
+ * - Express/framework middleware: use, get, post, put, delete, patch
41
+ * - Custom application HOFs
42
+ */
43
+ const INLINE_CALLBACK_METHODS = new Set([
44
+ // Array methods
45
+ 'map',
46
+ 'filter',
47
+ 'reduce',
48
+ 'forEach',
49
+ 'find',
50
+ 'findIndex',
51
+ 'some',
52
+ 'every',
53
+ 'flatMap',
54
+ 'sort',
55
+ 'toSorted',
56
+ // Promise methods
57
+ 'then',
58
+ 'catch',
59
+ 'finally',
60
+ // Timing
61
+ 'setTimeout',
62
+ 'setInterval',
63
+ // Events
64
+ 'addEventListener',
65
+ 'on',
66
+ 'once',
67
+ ])
68
+
33
69
  type FunctionLikeNode =
34
70
  | FunctionDeclaration
35
71
  | MethodDeclaration
@@ -148,12 +184,28 @@ export function extractFunctions(sourceFile: SourceFile): ExtractedFunction[] {
148
184
  if (node.getKind() === SyntaxKind.ArrowFunction) {
149
185
  const arrowFn = node as ArrowFunction
150
186
  const parentContext = inferParentContext(arrowFn)
187
+ const isInlineCallback = isInlineCallbackToKnownHOF(parentContext)
188
+ const enclosingFunctionStartLine = isInlineCallback
189
+ ? findEnclosingFunctionStartLine(arrowFn)
190
+ : null
151
191
  addFunction(
152
- extractFunctionData(arrowFn, filePath, 'arrow', parentContext, false),
192
+ extractFunctionData(
193
+ arrowFn,
194
+ filePath,
195
+ 'arrow',
196
+ parentContext,
197
+ false,
198
+ isInlineCallback,
199
+ enclosingFunctionStartLine,
200
+ ),
153
201
  )
154
202
  } else if (node.getKind() === SyntaxKind.FunctionExpression) {
155
203
  const funcExpr = node as FunctionExpression
156
204
  const parentContext = inferParentContext(funcExpr)
205
+ const isInlineCallback = isInlineCallbackToKnownHOF(parentContext)
206
+ const enclosingFunctionStartLine = isInlineCallback
207
+ ? findEnclosingFunctionStartLine(funcExpr)
208
+ : null
157
209
  addFunction(
158
210
  extractFunctionData(
159
211
  funcExpr,
@@ -161,6 +213,8 @@ export function extractFunctions(sourceFile: SourceFile): ExtractedFunction[] {
161
213
  'function-expression',
162
214
  parentContext,
163
215
  false,
216
+ isInlineCallback,
217
+ enclosingFunctionStartLine,
164
218
  ),
165
219
  )
166
220
  }
@@ -169,6 +223,39 @@ export function extractFunctions(sourceFile: SourceFile): ExtractedFunction[] {
169
223
  return functions
170
224
  }
171
225
 
226
+ /**
227
+ * Check if a function is an inline callback to a known HOF
228
+ */
229
+ function isInlineCallbackToKnownHOF(parentContext: string | null): boolean {
230
+ if (parentContext === null) return false
231
+ return INLINE_CALLBACK_METHODS.has(parentContext)
232
+ }
233
+
234
+ /**
235
+ * Find the start line of the enclosing function for a node.
236
+ * Returns null if the node is at module scope (no enclosing function).
237
+ */
238
+ function findEnclosingFunctionStartLine(node: Node): number | null {
239
+ let current = node.getParent()
240
+
241
+ while (current) {
242
+ const kind = current.getKind()
243
+ if (
244
+ kind === SyntaxKind.FunctionDeclaration ||
245
+ kind === SyntaxKind.MethodDeclaration ||
246
+ kind === SyntaxKind.ArrowFunction ||
247
+ kind === SyntaxKind.FunctionExpression ||
248
+ kind === SyntaxKind.GetAccessor ||
249
+ kind === SyntaxKind.SetAccessor
250
+ ) {
251
+ return current.getStartLineNumber()
252
+ }
253
+ current = current.getParent()
254
+ }
255
+
256
+ return null // module-scope, no enclosing function
257
+ }
258
+
172
259
  /**
173
260
  * Infer a meaningful name/context for an arrow function or function expression
174
261
  * based on its position in the AST (e.g., method name it's passed to, property name, etc.)
@@ -221,6 +308,8 @@ function extractFunctionData(
221
308
  kind: ExtractedFunction['kind'],
222
309
  parentContext: string | null = null,
223
310
  isExportedOverride?: boolean,
311
+ isInlineCallback: boolean = false,
312
+ enclosingFunctionStartLine: number | null = null,
224
313
  ): ExtractedFunction {
225
314
  const startLine = node.getStartLineNumber()
226
315
  const endLine = node.getEndLineNumber()
@@ -280,6 +369,8 @@ function extractFunctionData(
280
369
  hasAwait,
281
370
  propertyAccessChains,
282
371
  kind,
372
+ enclosingFunctionStartLine,
373
+ isInlineCallback,
283
374
  }
284
375
  }
285
376
 
@@ -336,14 +427,27 @@ function isStatement(node: Node): boolean {
336
427
 
337
428
  /**
338
429
  * Check if a function body contains conditionals
430
+ * Note: Does not descend into nested functions (arrow functions, function expressions, etc.)
339
431
  */
340
432
  function checkForConditionals(body: Node | undefined): boolean {
341
433
  if (!body) return false
342
434
 
343
435
  let hasConditionals = false
344
436
 
345
- body.forEachDescendant(node => {
437
+ body.forEachDescendant((node, traversal) => {
346
438
  const kind = node.getKind()
439
+
440
+ // Don't descend into nested functions
441
+ if (
442
+ kind === SyntaxKind.FunctionDeclaration ||
443
+ kind === SyntaxKind.FunctionExpression ||
444
+ kind === SyntaxKind.ArrowFunction ||
445
+ kind === SyntaxKind.MethodDeclaration
446
+ ) {
447
+ traversal.skip()
448
+ return
449
+ }
450
+
347
451
  if (
348
452
  kind === SyntaxKind.IfStatement ||
349
453
  kind === SyntaxKind.ConditionalExpression ||
@@ -8,80 +8,38 @@
8
8
  import { Project, SourceFile } from 'ts-morph'
9
9
  import * as path from 'node:path'
10
10
  import * as fs from 'node:fs'
11
+ import * as jsonc from 'jsonc-parser'
11
12
 
12
13
  /**
13
- * Strips JSON comments (both line // and block /* comments)
14
- * This allows parsing tsconfig.json files that contain comments
14
+ * Result of validating tsconfig content
15
15
  */
16
- export function stripJsonComments(jsonString: string): string {
17
- let result = ''
18
- let inString = false
19
- let inLineComment = false
20
- let inBlockComment = false
21
- let i = 0
22
-
23
- while (i < jsonString.length) {
24
- const char = jsonString[i]
25
- const nextChar = jsonString[i + 1]
26
-
27
- // Handle string state (don't strip comments inside strings)
28
- if (!inLineComment && !inBlockComment && char === '"') {
29
- // Check if this quote is escaped
30
- let backslashCount = 0
31
- let j = i - 1
32
- while (j >= 0 && jsonString[j] === '\\') {
33
- backslashCount++
34
- j--
35
- }
36
- if (backslashCount % 2 === 0) {
37
- inString = !inString
38
- }
39
- }
40
-
41
- if (inString) {
42
- result += char
43
- i++
44
- continue
45
- }
46
-
47
- // Handle line comment start
48
- if (!inBlockComment && char === '/' && nextChar === '/') {
49
- inLineComment = true
50
- i += 2
51
- continue
52
- }
53
-
54
- // Handle line comment end
55
- if (inLineComment && (char === '\n' || char === '\r')) {
56
- inLineComment = false
57
- result += char
58
- i++
59
- continue
60
- }
61
-
62
- // Handle block comment start
63
- if (!inLineComment && char === '/' && nextChar === '*') {
64
- inBlockComment = true
65
- i += 2
66
- continue
67
- }
16
+ export type TsconfigValidationResult =
17
+ | { valid: true }
18
+ | { valid: false; error: string }
68
19
 
69
- // Handle block comment end
70
- if (inBlockComment && char === '*' && nextChar === '/') {
71
- inBlockComment = false
72
- i += 2
73
- continue
74
- }
75
-
76
- // Add character if not in a comment
77
- if (!inLineComment && !inBlockComment) {
78
- result += char
20
+ /**
21
+ * Validates tsconfig.json content (JSONC format with comments and trailing commas)
22
+ *
23
+ * This is a pure function that can be used by both the CLI and the extractor
24
+ * to validate tsconfig content without duplication.
25
+ *
26
+ * @param content - The raw tsconfig.json file content
27
+ * @returns Validation result indicating success or failure with error message
28
+ */
29
+ export function validateTsconfigContent(
30
+ content: string,
31
+ ): TsconfigValidationResult {
32
+ const errors: jsonc.ParseError[] = []
33
+ jsonc.parse(content, errors, { allowTrailingComma: true })
34
+
35
+ if (errors.length > 0 && errors[0]) {
36
+ return {
37
+ valid: false,
38
+ error: `Parse error at offset ${errors[0].offset}: ${jsonc.printParseErrorCode(errors[0].error)}`,
79
39
  }
80
-
81
- i++
82
40
  }
83
41
 
84
- return result
42
+ return { valid: true }
85
43
  }
86
44
 
87
45
  export type ExtractorOptions = {
@@ -108,18 +66,17 @@ export function loadProject(options: ExtractorOptions): ExtractorResult {
108
66
 
109
67
  // Validate tsconfig exists
110
68
  const absoluteTsconfigPath = path.resolve(tsconfigPath)
69
+ const projectDir = path.dirname(absoluteTsconfigPath)
111
70
  if (!fs.existsSync(absoluteTsconfigPath)) {
112
71
  throw new Error(`tsconfig.json not found at: ${absoluteTsconfigPath}`)
113
72
  }
114
73
 
115
- // Validate tsconfig is valid JSON (strip comments first since TypeScript allows them)
116
- try {
117
- const content = fs.readFileSync(absoluteTsconfigPath, 'utf-8')
118
- const strippedContent = stripJsonComments(content)
119
- JSON.parse(strippedContent)
120
- } catch (e) {
74
+ // Validate tsconfig is valid JSONC (TypeScript allows comments and trailing commas)
75
+ const content = fs.readFileSync(absoluteTsconfigPath, 'utf-8')
76
+ const validationResult = validateTsconfigContent(content)
77
+ if (!validationResult.valid) {
121
78
  throw new Error(
122
- `Invalid tsconfig.json at ${absoluteTsconfigPath}: ${e instanceof Error ? e.message : String(e)}`,
79
+ `Invalid tsconfig.json at ${absoluteTsconfigPath}: ${validationResult.error}`,
123
80
  )
124
81
  }
125
82
 
@@ -130,10 +87,14 @@ export function loadProject(options: ExtractorOptions): ExtractorResult {
130
87
  })
131
88
 
132
89
  // Get source files, filtering to .ts only (excluding .tsx for v1)
90
+ // Also filter to only files under the tsconfig directory to exclude
91
+ // transitive dependencies from other packages
133
92
  let sourceFiles = project.getSourceFiles().filter(sf => {
134
93
  const filePath = sf.getFilePath()
135
94
  // Include only .ts files, exclude .tsx, .d.ts, test files, and generated files
95
+ // Must be under the project directory (not transitive deps from other packages)
136
96
  return (
97
+ filePath.startsWith(projectDir + '/') &&
137
98
  filePath.endsWith('.ts') &&
138
99
  !filePath.endsWith('.d.ts') &&
139
100
  !filePath.endsWith('.test.ts') &&