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/.plans/003-code-cleanup-consolidation.md +242 -0
- package/.plans/004-directory-depth-rollup.md +408 -0
- package/.plans/005-code-refinements.md +210 -0
- package/.plans/006-minor-refinements.md +149 -0
- package/.plans/007-compositional-function-scoring.md +514 -0
- package/README.md +38 -3
- package/TECHNICAL.md +125 -2
- package/dist/cli.js +595 -327
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +15 -2
- package/dist/index.js +409 -240
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/cli-utils.ts +201 -0
- package/src/cli.ts +99 -117
- package/src/detection/markers.ts +0 -222
- package/src/extraction/extract-functions.ts +106 -2
- package/src/extraction/extractor.ts +35 -74
- package/src/reporting/report-console.ts +188 -102
- package/src/reporting/report-json.ts +26 -3
- package/src/scoring/scorer.ts +425 -160
- package/src/types.ts +9 -2
- package/tests/classifier.test.ts +0 -1
- package/tests/cli.test.ts +356 -0
- package/tests/detect-markers.test.ts +1 -3
- package/tests/extractor.test.ts +95 -1
- package/tests/integration.test.ts +344 -0
- package/tests/report-console.test.ts +92 -0
- package/tests/scorer.test.ts +886 -0
package/src/detection/markers.ts
CHANGED
|
@@ -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(
|
|
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
|
-
*
|
|
14
|
-
* This allows parsing tsconfig.json files that contain comments
|
|
14
|
+
* Result of validating tsconfig content
|
|
15
15
|
*/
|
|
16
|
-
export
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
|
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
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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}: ${
|
|
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') &&
|