fcis 0.1.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.
Files changed (151) hide show
  1. package/.plans/001-fcis-analyzer.md +832 -0
  2. package/.plans/002-fcis-analyzer-improvements.md +205 -0
  3. package/README.md +272 -0
  4. package/TECHNICAL.md +386 -0
  5. package/dist/cli.d.ts +1 -0
  6. package/dist/cli.js +1836 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/index.d.ts +709 -0
  9. package/dist/index.js +1845 -0
  10. package/dist/index.js.map +1 -0
  11. package/package.json +47 -0
  12. package/pnpm-workspace.yaml +0 -0
  13. package/src/analyzer.ts +266 -0
  14. package/src/classification/classifier.ts +156 -0
  15. package/src/classification/derive-status.ts +171 -0
  16. package/src/classification/quality-scorer.ts +481 -0
  17. package/src/cli.ts +286 -0
  18. package/src/detection/detect-markers.ts +480 -0
  19. package/src/detection/markers.ts +332 -0
  20. package/src/extraction/extract-functions.ts +570 -0
  21. package/src/extraction/extractor.ts +188 -0
  22. package/src/index.ts +111 -0
  23. package/src/reporting/report-console.ts +416 -0
  24. package/src/reporting/report-json.ts +232 -0
  25. package/src/scoring/scorer.ts +504 -0
  26. package/src/types.ts +248 -0
  27. package/tests/classifier.test.ts +480 -0
  28. package/tests/derive-status.test.ts +464 -0
  29. package/tests/detect-markers.test.ts +639 -0
  30. package/tests/extractor.test.ts +155 -0
  31. package/tests/integration.test.ts +706 -0
  32. package/tests/quality-scorer.test.ts +650 -0
  33. package/tests/scorer.test.ts +768 -0
  34. package/tsconfig.json +34 -0
  35. package/tsup.config.ts +17 -0
  36. package/vendor/ts-morph/.editorconfig +10 -0
  37. package/vendor/ts-morph/.gitattributes +11 -0
  38. package/vendor/ts-morph/.github/CODE_OF_CONDUCT.md +77 -0
  39. package/vendor/ts-morph/.github/ISSUE_TEMPLATE/bug_report.md +29 -0
  40. package/vendor/ts-morph/.github/ISSUE_TEMPLATE/custom.md +4 -0
  41. package/vendor/ts-morph/.github/ISSUE_TEMPLATE/feature_request.md +18 -0
  42. package/vendor/ts-morph/.github/workflows/ci.yml +50 -0
  43. package/vendor/ts-morph/.github/workflows/publish.yml +53 -0
  44. package/vendor/ts-morph/.vscode/settings.json +10 -0
  45. package/vendor/ts-morph/CONTRIBUTING.md +23 -0
  46. package/vendor/ts-morph/DEVELOPMENT.md +32 -0
  47. package/vendor/ts-morph/LICENSE +21 -0
  48. package/vendor/ts-morph/deno.json +8 -0
  49. package/vendor/ts-morph/deno.lock +1233 -0
  50. package/vendor/ts-morph/docs/CNAME +1 -0
  51. package/vendor/ts-morph/docs/Gemfile +2 -0
  52. package/vendor/ts-morph/docs/_config.yml +5 -0
  53. package/vendor/ts-morph/docs/_layouts/default.html +159 -0
  54. package/vendor/ts-morph/docs/_script-templates/main.ts +116 -0
  55. package/vendor/ts-morph/docs/assets/css/style.scss +212 -0
  56. package/vendor/ts-morph/docs/details/ambient.md +38 -0
  57. package/vendor/ts-morph/docs/details/async.md +31 -0
  58. package/vendor/ts-morph/docs/details/classes.md +314 -0
  59. package/vendor/ts-morph/docs/details/comment-ranges.md +7 -0
  60. package/vendor/ts-morph/docs/details/comments.md +122 -0
  61. package/vendor/ts-morph/docs/details/decorators.md +119 -0
  62. package/vendor/ts-morph/docs/details/documentation.md +73 -0
  63. package/vendor/ts-morph/docs/details/enums.md +117 -0
  64. package/vendor/ts-morph/docs/details/exports.md +308 -0
  65. package/vendor/ts-morph/docs/details/expressions.md +46 -0
  66. package/vendor/ts-morph/docs/details/functions.md +150 -0
  67. package/vendor/ts-morph/docs/details/generators.md +27 -0
  68. package/vendor/ts-morph/docs/details/identifiers.md +79 -0
  69. package/vendor/ts-morph/docs/details/imports.md +191 -0
  70. package/vendor/ts-morph/docs/details/index.md +52 -0
  71. package/vendor/ts-morph/docs/details/initializers.md +40 -0
  72. package/vendor/ts-morph/docs/details/interfaces.md +218 -0
  73. package/vendor/ts-morph/docs/details/literals.md +20 -0
  74. package/vendor/ts-morph/docs/details/modifiers.md +38 -0
  75. package/vendor/ts-morph/docs/details/modules.md +113 -0
  76. package/vendor/ts-morph/docs/details/namespaces.md +7 -0
  77. package/vendor/ts-morph/docs/details/object-literal-expressions.md +106 -0
  78. package/vendor/ts-morph/docs/details/parameters.md +64 -0
  79. package/vendor/ts-morph/docs/details/signatures.md +41 -0
  80. package/vendor/ts-morph/docs/details/source-files.md +292 -0
  81. package/vendor/ts-morph/docs/details/type-aliases.md +34 -0
  82. package/vendor/ts-morph/docs/details/type-parameters.md +72 -0
  83. package/vendor/ts-morph/docs/details/types.md +254 -0
  84. package/vendor/ts-morph/docs/details/variables.md +110 -0
  85. package/vendor/ts-morph/docs/emitting.md +151 -0
  86. package/vendor/ts-morph/docs/index.md +25 -0
  87. package/vendor/ts-morph/docs/manipulation/code-writer.md +20 -0
  88. package/vendor/ts-morph/docs/manipulation/formatting.md +76 -0
  89. package/vendor/ts-morph/docs/manipulation/index.md +136 -0
  90. package/vendor/ts-morph/docs/manipulation/order.md +14 -0
  91. package/vendor/ts-morph/docs/manipulation/performance.md +222 -0
  92. package/vendor/ts-morph/docs/manipulation/removing.md +31 -0
  93. package/vendor/ts-morph/docs/manipulation/renaming.md +106 -0
  94. package/vendor/ts-morph/docs/manipulation/settings.md +76 -0
  95. package/vendor/ts-morph/docs/manipulation/structures.md +117 -0
  96. package/vendor/ts-morph/docs/manipulation/transforms.md +84 -0
  97. package/vendor/ts-morph/docs/metrics/performance.json +4 -0
  98. package/vendor/ts-morph/docs/navigation/ambient-modules.md +22 -0
  99. package/vendor/ts-morph/docs/navigation/compiler-nodes.md +82 -0
  100. package/vendor/ts-morph/docs/navigation/directories.md +287 -0
  101. package/vendor/ts-morph/docs/navigation/example.md +50 -0
  102. package/vendor/ts-morph/docs/navigation/finding-references.md +53 -0
  103. package/vendor/ts-morph/docs/navigation/getting-source-files.md +59 -0
  104. package/vendor/ts-morph/docs/navigation/images/getChildrenVsForEachChild.gif +0 -0
  105. package/vendor/ts-morph/docs/navigation/index.md +94 -0
  106. package/vendor/ts-morph/docs/navigation/language-service.md +23 -0
  107. package/vendor/ts-morph/docs/navigation/program.md +25 -0
  108. package/vendor/ts-morph/docs/navigation/type-checker.md +33 -0
  109. package/vendor/ts-morph/docs/setup/adding-source-files.md +145 -0
  110. package/vendor/ts-morph/docs/setup/ast-viewers.md +46 -0
  111. package/vendor/ts-morph/docs/setup/diagnostics.md +109 -0
  112. package/vendor/ts-morph/docs/setup/file-system.md +106 -0
  113. package/vendor/ts-morph/docs/setup/images/atom-ast.png +0 -0
  114. package/vendor/ts-morph/docs/setup/images/atom-ast_small.png +0 -0
  115. package/vendor/ts-morph/docs/setup/images/atom-command-palette.png +0 -0
  116. package/vendor/ts-morph/docs/setup/images/atom-file.png +0 -0
  117. package/vendor/ts-morph/docs/setup/images/ts-ast-viewer.png +0 -0
  118. package/vendor/ts-morph/docs/setup/index.md +94 -0
  119. package/vendor/ts-morph/docs/utilities.md +55 -0
  120. package/vendor/ts-morph/dprint.json +23 -0
  121. package/vendor/ts-morph/package.json +30 -0
  122. package/vendor/ts-morph/packages/bootstrap/LICENSE +21 -0
  123. package/vendor/ts-morph/packages/bootstrap/lib/ts-morph-bootstrap.d.ts +397 -0
  124. package/vendor/ts-morph/packages/bootstrap/package.json +46 -0
  125. package/vendor/ts-morph/packages/bootstrap/readme.md +200 -0
  126. package/vendor/ts-morph/packages/common/LICENSE +21 -0
  127. package/vendor/ts-morph/packages/common/lib/ts-morph-common.d.ts +1082 -0
  128. package/vendor/ts-morph/packages/common/lib/typescript.d.ts +11439 -0
  129. package/vendor/ts-morph/packages/common/package.json +65 -0
  130. package/vendor/ts-morph/packages/common/readme.md +5 -0
  131. package/vendor/ts-morph/packages/scripts/changeTypeScriptVersion.ts +28 -0
  132. package/vendor/ts-morph/packages/scripts/createDeclarationProject.ts +47 -0
  133. package/vendor/ts-morph/packages/scripts/deps.ts +2 -0
  134. package/vendor/ts-morph/packages/scripts/execScript.ts +31 -0
  135. package/vendor/ts-morph/packages/scripts/folders.ts +11 -0
  136. package/vendor/ts-morph/packages/scripts/getDevCompilerVersions.ts +19 -0
  137. package/vendor/ts-morph/packages/scripts/mod.ts +7 -0
  138. package/vendor/ts-morph/packages/scripts/utils/Memoize.ts +36 -0
  139. package/vendor/ts-morph/packages/scripts/utils/forEachTypeText.ts +23 -0
  140. package/vendor/ts-morph/packages/scripts/utils/makeConstructorsPrivate.ts +26 -0
  141. package/vendor/ts-morph/packages/scripts/utils/mod.ts +4 -0
  142. package/vendor/ts-morph/packages/scripts/utils/printDiagnostics.ts +10 -0
  143. package/vendor/ts-morph/packages/ts-morph/LICENSE +21 -0
  144. package/vendor/ts-morph/packages/ts-morph/lib/ts-morph.d.ts +11198 -0
  145. package/vendor/ts-morph/packages/ts-morph/package.json +78 -0
  146. package/vendor/ts-morph/packages/ts-morph/readme.md +111 -0
  147. package/vendor/ts-morph/readme.md +14 -0
  148. package/vendor/ts-morph/rfcs/README.md +13 -0
  149. package/vendor/ts-morph/rfcs/RFC-0001 - Inserting Into Statements Handling Comments.md +181 -0
  150. package/vendor/ts-morph/tsconfig.common.json +17 -0
  151. package/vitest.config.ts +16 -0
@@ -0,0 +1,706 @@
1
+ /**
2
+ * Integration Tests
3
+ *
4
+ * Tests for the full analysis pipeline using ts-morph's in-memory file system.
5
+ * These tests verify that extraction, detection, classification, and scoring
6
+ * work correctly together on real TypeScript ASTs.
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest'
10
+ import { Project } from 'ts-morph'
11
+
12
+ import {
13
+ extractFunctions,
14
+ extractImports,
15
+ isTypeOnlyFile,
16
+ } from '../src/extraction/extract-functions.js'
17
+ import {
18
+ detectMarkers,
19
+ createDetectionContext,
20
+ } from '../src/detection/detect-markers.js'
21
+ import {
22
+ classifyFunction,
23
+ shouldExcludeFunction,
24
+ createClassifiedFunction,
25
+ } from '../src/classification/classifier.js'
26
+ import { computeQualityScore } from '../src/classification/quality-scorer.js'
27
+ import { deriveStatus } from '../src/classification/derive-status.js'
28
+ import {
29
+ scoreFile,
30
+ scoreProject,
31
+ scoreDirectory,
32
+ } from '../src/scoring/scorer.js'
33
+ import type { ClassifiedFunction } from '../src/types.js'
34
+
35
+ /**
36
+ * Helper to create an in-memory ts-morph project with test files
37
+ */
38
+ function createTestProject(files: Record<string, string>): Project {
39
+ const project = new Project({
40
+ useInMemoryFileSystem: true,
41
+ compilerOptions: {
42
+ target: 99, // ESNext
43
+ module: 99, // ESNext
44
+ strict: true,
45
+ },
46
+ })
47
+
48
+ for (const [path, content] of Object.entries(files)) {
49
+ project.createSourceFile(path, content)
50
+ }
51
+
52
+ return project
53
+ }
54
+
55
+ /**
56
+ * Helper to run the full analysis pipeline on a source file
57
+ */
58
+ function analyzeSourceFile(
59
+ project: Project,
60
+ filePath: string,
61
+ ): ClassifiedFunction[] {
62
+ const sourceFile = project.getSourceFile(filePath)
63
+ if (!sourceFile) {
64
+ throw new Error(`File not found: ${filePath}`)
65
+ }
66
+
67
+ const functions = extractFunctions(sourceFile)
68
+ const imports = extractImports(sourceFile)
69
+ const context = createDetectionContext(imports)
70
+
71
+ const classifiedFunctions: ClassifiedFunction[] = []
72
+
73
+ for (const fn of functions) {
74
+ if (shouldExcludeFunction(fn)) {
75
+ continue
76
+ }
77
+
78
+ const markers = detectMarkers(fn, context)
79
+ const classification = classifyFunction(fn, markers)
80
+
81
+ let qualityScore: number | null = null
82
+ if (classification === 'impure') {
83
+ qualityScore = computeQualityScore(fn, markers, context)
84
+ }
85
+
86
+ const status = deriveStatus(classification, qualityScore)
87
+
88
+ classifiedFunctions.push(
89
+ createClassifiedFunction(fn, markers, qualityScore, status),
90
+ )
91
+ }
92
+
93
+ return classifiedFunctions
94
+ }
95
+
96
+ describe('Integration: Full Pipeline', () => {
97
+ describe('pure function detection', () => {
98
+ it('should classify a pure function with no I/O', () => {
99
+ const project = createTestProject({
100
+ '/src/math.ts': `
101
+ export function add(a: number, b: number): number {
102
+ const result = a + b
103
+ const doubled = result * 2
104
+ const final = doubled - result
105
+ return final
106
+ }
107
+ `,
108
+ })
109
+
110
+ const functions = analyzeSourceFile(project, '/src/math.ts')
111
+
112
+ expect(functions).toHaveLength(1)
113
+ expect(functions[0]?.classification).toBe('pure')
114
+ expect(functions[0]?.status).toBe('ok')
115
+ expect(functions[0]?.qualityScore).toBeNull()
116
+ expect(functions[0]?.markers).toHaveLength(0)
117
+ })
118
+
119
+ it('should classify async function without await as pure', () => {
120
+ const project = createTestProject({
121
+ '/src/async-pure.ts': `
122
+ export async function computeAsync(data: number[]): Promise<number> {
123
+ const sum = data.reduce((a, b) => a + b, 0)
124
+ const avg = sum / data.length
125
+ return avg
126
+ }
127
+ `,
128
+ })
129
+
130
+ const functions = analyzeSourceFile(project, '/src/async-pure.ts')
131
+
132
+ expect(functions).toHaveLength(1)
133
+ expect(functions[0]?.classification).toBe('pure')
134
+ expect(functions[0]?.isAsync).toBe(true)
135
+ })
136
+
137
+ it('should classify predicate functions as pure', () => {
138
+ const project = createTestProject({
139
+ '/src/predicates.ts': `
140
+ export function isValid(value: unknown): boolean {
141
+ if (value === null || value === undefined) {
142
+ return false
143
+ }
144
+ const isString = typeof value === 'string'
145
+ const hasLength = isString && value.length > 0
146
+ return hasLength
147
+ }
148
+
149
+ export function hasPermission(user: { roles: string[] }, required: string): boolean {
150
+ const roles = user.roles
151
+ const hasRole = roles.includes(required)
152
+ const isAdmin = roles.includes('admin')
153
+ return hasRole || isAdmin
154
+ }
155
+ `,
156
+ })
157
+
158
+ const functions = analyzeSourceFile(project, '/src/predicates.ts')
159
+
160
+ expect(functions).toHaveLength(2)
161
+ expect(functions.every(f => f.classification === 'pure')).toBe(true)
162
+ })
163
+ })
164
+
165
+ describe('impure function detection', () => {
166
+ it('should classify function with database call as impure', () => {
167
+ const project = createTestProject({
168
+ '/src/users.ts': `
169
+ export async function getUser(id: string) {
170
+ const whereClause = { id }
171
+ const user = await db.user.findFirst({ where: whereClause })
172
+ const result = user ? user : null
173
+ return result
174
+ }
175
+ `,
176
+ })
177
+
178
+ const functions = analyzeSourceFile(project, '/src/users.ts')
179
+
180
+ expect(functions).toHaveLength(1)
181
+ expect(functions[0]?.classification).toBe('impure')
182
+ expect(functions[0]?.markers.some(m => m.type === 'database-call')).toBe(
183
+ true,
184
+ )
185
+ expect(
186
+ functions[0]?.markers.some(m => m.type === 'await-expression'),
187
+ ).toBe(true)
188
+ })
189
+
190
+ it('should classify function with fetch call as impure', () => {
191
+ const project = createTestProject({
192
+ '/src/api.ts': `
193
+ export async function fetchData(url: string) {
194
+ const response = await fetch(url)
195
+ const data = await response.json()
196
+ return data
197
+ }
198
+ `,
199
+ })
200
+
201
+ const functions = analyzeSourceFile(project, '/src/api.ts')
202
+
203
+ expect(functions).toHaveLength(1)
204
+ expect(functions[0]?.classification).toBe('impure')
205
+ expect(functions[0]?.markers.some(m => m.type === 'network-fetch')).toBe(
206
+ true,
207
+ )
208
+ })
209
+
210
+ it('should classify function with console.log as impure', () => {
211
+ const project = createTestProject({
212
+ '/src/logger.ts': `
213
+ export function logMessage(message: string): void {
214
+ const timestamp = new Date().toISOString()
215
+ const formatted = \`[\${timestamp}] \${message}\`
216
+ const output = formatted.trim()
217
+ console.log(output)
218
+ }
219
+ `,
220
+ })
221
+
222
+ const functions = analyzeSourceFile(project, '/src/logger.ts')
223
+
224
+ expect(functions).toHaveLength(1)
225
+ expect(functions[0]?.classification).toBe('impure')
226
+ expect(functions[0]?.markers.some(m => m.type === 'console-log')).toBe(
227
+ true,
228
+ )
229
+ })
230
+
231
+ it('should classify function with process.env access as impure', () => {
232
+ const project = createTestProject({
233
+ '/src/config.ts': `
234
+ export function getConfig() {
235
+ const apiUrl = process.env.API_URL
236
+ const debug = process.env.DEBUG === 'true'
237
+ return { apiUrl, debug }
238
+ }
239
+ `,
240
+ })
241
+
242
+ const functions = analyzeSourceFile(project, '/src/config.ts')
243
+
244
+ expect(functions).toHaveLength(1)
245
+ expect(functions[0]?.classification).toBe('impure')
246
+ expect(functions[0]?.markers.some(m => m.type === 'env-access')).toBe(
247
+ true,
248
+ )
249
+ })
250
+ })
251
+
252
+ describe('quality scoring', () => {
253
+ it('should give high quality score to well-structured impure function', () => {
254
+ const project = createTestProject({
255
+ '/src/service.ts': `
256
+ import { planAcceptInvite } from './domain.pure'
257
+
258
+ export async function handleAcceptInvite(inviteId: string) {
259
+ // GATHER
260
+ const invite = await db.invitation.findFirst({ where: { id: inviteId } })
261
+ const org = await db.organization.findFirst({ where: { id: invite.orgId } })
262
+
263
+ // DECIDE (pure)
264
+ const plan = planAcceptInvite(invite, org)
265
+
266
+ // EXECUTE
267
+ if (plan.shouldAccept) {
268
+ await db.member.create({ data: plan.memberData })
269
+ await db.invitation.update({ where: { id: inviteId }, data: { accepted: true } })
270
+ }
271
+
272
+ return plan.result
273
+ }
274
+ `,
275
+ '/src/domain.pure.ts': `
276
+ export function planAcceptInvite(invite: any, org: any) {
277
+ return { shouldAccept: true, memberData: {}, result: 'accepted' }
278
+ }
279
+ `,
280
+ })
281
+
282
+ const functions = analyzeSourceFile(project, '/src/service.ts')
283
+
284
+ expect(functions).toHaveLength(1)
285
+ expect(functions[0]?.classification).toBe('impure')
286
+ // Should have decent quality due to calling pure function
287
+ expect(functions[0]?.qualityScore).toBeGreaterThanOrEqual(50)
288
+ })
289
+
290
+ it('should give low quality score to tangled impure function', () => {
291
+ const project = createTestProject({
292
+ '/src/tangled.ts': `
293
+ export async function processOrder(orderId: string) {
294
+ const order = await db.order.findFirst({ where: { id: orderId } })
295
+ console.log('Processing order', order)
296
+
297
+ if (order.status === 'pending') {
298
+ const user = await db.user.findFirst({ where: { id: order.userId } })
299
+ console.log('Found user', user)
300
+
301
+ const items = await db.orderItem.findMany({ where: { orderId } })
302
+
303
+ for (const item of items) {
304
+ const product = await db.product.findFirst({ where: { id: item.productId } })
305
+ console.log('Processing item', item, product)
306
+
307
+ if (product.stock < item.quantity) {
308
+ await fetch('/api/restock', { method: 'POST', body: JSON.stringify({ productId: product.id }) })
309
+ }
310
+
311
+ await db.product.update({ where: { id: product.id }, data: { stock: product.stock - item.quantity } })
312
+ }
313
+
314
+ await db.order.update({ where: { id: orderId }, data: { status: 'processed' } })
315
+ console.log('Order processed')
316
+ }
317
+
318
+ return order
319
+ }
320
+ `,
321
+ })
322
+
323
+ const functions = analyzeSourceFile(project, '/src/tangled.ts')
324
+
325
+ expect(functions).toHaveLength(1)
326
+ expect(functions[0]?.classification).toBe('impure')
327
+ expect(functions[0]?.qualityScore).toBeLessThan(50)
328
+ expect(functions[0]?.status).toBe('refactor')
329
+ })
330
+ })
331
+
332
+ describe('file scoring', () => {
333
+ it('should score a file with mixed functions', () => {
334
+ const project = createTestProject({
335
+ '/src/mixed.ts': `
336
+ // Pure function
337
+ export function calculateTotal(items: { price: number }[]): number {
338
+ const prices = items.map(item => item.price)
339
+ const sum = prices.reduce((acc, price) => acc + price, 0)
340
+ const rounded = Math.round(sum * 100) / 100
341
+ return rounded
342
+ }
343
+
344
+ // Pure function
345
+ export function formatCurrency(amount: number): string {
346
+ const fixed = amount.toFixed(2)
347
+ const formatted = \`$\${fixed}\`
348
+ const trimmed = formatted.trim()
349
+ return trimmed
350
+ }
351
+
352
+ // Impure function
353
+ export async function saveOrder(order: any) {
354
+ const total = calculateTotal(order.items)
355
+ const formatted = formatCurrency(total)
356
+ const data = { ...order, total }
357
+ console.log('Saving order with total:', formatted)
358
+ await db.order.create({ data })
359
+ }
360
+ `,
361
+ })
362
+
363
+ const functions = analyzeSourceFile(project, '/src/mixed.ts')
364
+ const fileScore = scoreFile('/src/mixed.ts', functions)
365
+
366
+ expect(fileScore.pureCount).toBe(2)
367
+ expect(fileScore.impureCount).toBe(1)
368
+ expect(fileScore.purity).toBeCloseTo(66.67, 1)
369
+ })
370
+
371
+ it('should handle type-only files', () => {
372
+ const project = createTestProject({
373
+ '/src/types.ts': `
374
+ export interface User {
375
+ id: string
376
+ name: string
377
+ email: string
378
+ }
379
+
380
+ export type UserRole = 'admin' | 'user' | 'guest'
381
+
382
+ export enum Status {
383
+ Active = 'active',
384
+ Inactive = 'inactive',
385
+ }
386
+ `,
387
+ })
388
+
389
+ const sourceFile = project.getSourceFile('/src/types.ts')!
390
+ const isTypeOnly = isTypeOnlyFile(sourceFile)
391
+
392
+ expect(isTypeOnly).toBe(true)
393
+ })
394
+ })
395
+
396
+ describe('extraction edge cases', () => {
397
+ it('should extract arrow functions from variable declarations', () => {
398
+ const project = createTestProject({
399
+ '/src/arrows.ts': `
400
+ export const add = (a: number, b: number): number => {
401
+ const sum = a + b
402
+ const doubled = sum * 2
403
+ const result = doubled / 2
404
+ return result
405
+ }
406
+
407
+ export const multiply = (a: number, b: number): number => {
408
+ const result = a * b
409
+ const squared = result * result
410
+ const final = Math.sqrt(squared)
411
+ return final
412
+ }
413
+ `,
414
+ })
415
+
416
+ const functions = analyzeSourceFile(project, '/src/arrows.ts')
417
+
418
+ expect(functions).toHaveLength(2)
419
+ expect(functions.every(f => f.kind === 'arrow')).toBe(true)
420
+ })
421
+
422
+ it('should use variable name for arrow functions', () => {
423
+ const project = createTestProject({
424
+ '/src/handlers.ts': `
425
+ export const handleSubmit = async (data: FormData) => {
426
+ const validated = validateData(data)
427
+ const transformed = transformData(validated)
428
+ const result = processData(transformed)
429
+ return result
430
+ }
431
+
432
+ export const validateInput = (input: string): boolean => {
433
+ const trimmed = input.trim()
434
+ const normalized = trimmed.toLowerCase()
435
+ const isValid = normalized.length > 0
436
+ return isValid
437
+ }
438
+ `,
439
+ })
440
+
441
+ const functions = analyzeSourceFile(project, '/src/handlers.ts')
442
+
443
+ expect(functions).toHaveLength(2)
444
+ expect(functions.map(f => f.name).sort()).toEqual([
445
+ 'handleSubmit',
446
+ 'validateInput',
447
+ ])
448
+ })
449
+
450
+ it('should extract inline arrow functions passed as arguments', () => {
451
+ const project = createTestProject({
452
+ '/src/router.ts': `
453
+ import { createRouter } from './trpc'
454
+
455
+ export const userRouter = createRouter({
456
+ getUser: query(async ({ ctx, input }) => {
457
+ const userId = input.id
458
+ const user = await ctx.db.user.findUnique({ where: { id: userId } })
459
+ const formatted = formatUser(user)
460
+ return formatted
461
+ }),
462
+
463
+ updateUser: mutation(async ({ ctx, input }) => {
464
+ const validated = validateInput(input)
465
+ const updated = await ctx.db.user.update({
466
+ where: { id: input.id },
467
+ data: validated,
468
+ })
469
+ return updated
470
+ }),
471
+ })
472
+ `,
473
+ })
474
+
475
+ const functions = analyzeSourceFile(project, '/src/router.ts')
476
+
477
+ // Should find the two inline handlers (query and mutation)
478
+ expect(functions.length).toBeGreaterThanOrEqual(2)
479
+
480
+ // Check that we found handlers named after their method
481
+ const names = functions.map(f => f.name)
482
+ expect(names).toContain('query')
483
+ expect(names).toContain('mutation')
484
+ })
485
+
486
+ it('should extract class methods', () => {
487
+ const project = createTestProject({
488
+ '/src/class.ts': `
489
+ export class Calculator {
490
+ add(a: number, b: number): number {
491
+ const sum = a + b
492
+ const doubled = sum * 2
493
+ const result = doubled / 2
494
+ return result
495
+ }
496
+
497
+ async fetchAndAdd(url: string, b: number): Promise<number> {
498
+ const response = await fetch(url)
499
+ const data = await response.json()
500
+ const a = data.value
501
+ const result = a + b
502
+ return result
503
+ }
504
+ }
505
+ `,
506
+ })
507
+
508
+ const functions = analyzeSourceFile(project, '/src/class.ts')
509
+
510
+ expect(functions).toHaveLength(2)
511
+ expect(
512
+ functions.some(f => f.kind === 'method' && f.classification === 'pure'),
513
+ ).toBe(true)
514
+ expect(
515
+ functions.some(
516
+ f => f.kind === 'method' && f.classification === 'impure',
517
+ ),
518
+ ).toBe(true)
519
+ })
520
+
521
+ it('should extract getters and setters', () => {
522
+ const project = createTestProject({
523
+ '/src/accessors.ts': `
524
+ export class Config {
525
+ private _value: string = ''
526
+
527
+ get value(): string {
528
+ console.log('Getting value')
529
+ return this._value
530
+ }
531
+
532
+ set value(newValue: string) {
533
+ this._value = newValue
534
+ }
535
+ }
536
+ `,
537
+ })
538
+
539
+ const sourceFile = project.getSourceFile('/src/accessors.ts')!
540
+ const functions = extractFunctions(sourceFile)
541
+
542
+ expect(functions.some(f => f.kind === 'getter')).toBe(true)
543
+ expect(functions.some(f => f.kind === 'setter')).toBe(true)
544
+ })
545
+ })
546
+
547
+ describe('full project scoring', () => {
548
+ it('should score a multi-file project', () => {
549
+ const project = createTestProject({
550
+ '/src/services/users.ts': `
551
+ export async function getUser(id: string) {
552
+ const whereClause = { id }
553
+ const user = await db.user.findFirst({ where: whereClause })
554
+ const result = user ? user : null
555
+ return result
556
+ }
557
+
558
+ export async function createUser(data: any) {
559
+ const userData = { ...data }
560
+ const created = await db.user.create({ data: userData })
561
+ const result = created
562
+ return result
563
+ }
564
+ `,
565
+ '/src/utils/math.ts': `
566
+ export function add(a: number, b: number): number {
567
+ const sum = a + b
568
+ const result = sum
569
+ const final = result
570
+ return final
571
+ }
572
+
573
+ export function multiply(a: number, b: number): number {
574
+ const product = a * b
575
+ const result = product
576
+ const final = result
577
+ return final
578
+ }
579
+
580
+ export function divide(a: number, b: number): number {
581
+ if (b === 0) throw new Error('Division by zero')
582
+ const quotient = a / b
583
+ const result = quotient
584
+ return result
585
+ }
586
+ `,
587
+ '/src/utils/format.ts': `
588
+ export function formatDate(date: Date): string {
589
+ const iso = date.toISOString()
590
+ const parts = iso.split('T')
591
+ const datePart = parts[0]
592
+ return datePart
593
+ }
594
+
595
+ export function formatCurrency(amount: number): string {
596
+ const fixed = amount.toFixed(2)
597
+ const formatted = \`$\${fixed}\`
598
+ const result = formatted
599
+ return result
600
+ }
601
+ `,
602
+ })
603
+
604
+ // Analyze each file
605
+ const servicesFunctions = analyzeSourceFile(
606
+ project,
607
+ '/src/services/users.ts',
608
+ )
609
+ const mathFunctions = analyzeSourceFile(project, '/src/utils/math.ts')
610
+ const formatFunctions = analyzeSourceFile(project, '/src/utils/format.ts')
611
+
612
+ // Score each file
613
+ const servicesScore = scoreFile(
614
+ '/src/services/users.ts',
615
+ servicesFunctions,
616
+ )
617
+ const mathScore = scoreFile('/src/utils/math.ts', mathFunctions)
618
+ const formatScore = scoreFile('/src/utils/format.ts', formatFunctions)
619
+
620
+ // Score directories
621
+ const servicesDir = scoreDirectory('/src/services', [servicesScore])
622
+ const utilsDir = scoreDirectory('/src/utils', [mathScore, formatScore])
623
+
624
+ // Score project
625
+ const projectScore = scoreProject([servicesDir, utilsDir], {
626
+ timestamp: '2024-01-01T00:00:00.000Z',
627
+ })
628
+
629
+ // Verify metrics
630
+ expect(projectScore.pureCount).toBe(5) // 3 math + 2 format
631
+ expect(projectScore.impureCount).toBe(2) // 2 services
632
+ expect(projectScore.purity).toBeCloseTo(71.43, 1) // 5/7
633
+
634
+ // Utils should have higher purity than services
635
+ expect(utilsDir.purity).toBe(100)
636
+ expect(servicesDir.purity).toBe(0)
637
+ })
638
+ })
639
+
640
+ describe('marker detection accuracy', () => {
641
+ it('should detect multiple marker types in one function', () => {
642
+ const project = createTestProject({
643
+ '/src/complex.ts': `
644
+ export async function complexOperation() {
645
+ console.log('Starting operation')
646
+
647
+ const config = process.env.CONFIG
648
+ const user = await db.user.findFirst({ where: { active: true } })
649
+ const external = await fetch('/api/data')
650
+
651
+ console.log('Operation complete')
652
+ return { config, user, external }
653
+ }
654
+ `,
655
+ })
656
+
657
+ const functions = analyzeSourceFile(project, '/src/complex.ts')
658
+
659
+ expect(functions).toHaveLength(1)
660
+ const markers = functions[0]!.markers
661
+
662
+ const markerTypes = new Set(markers.map(m => m.type))
663
+ expect(markerTypes.has('console-log')).toBe(true)
664
+ expect(markerTypes.has('env-access')).toBe(true)
665
+ expect(markerTypes.has('database-call')).toBe(true)
666
+ expect(markerTypes.has('network-fetch')).toBe(true)
667
+ expect(markerTypes.has('await-expression')).toBe(true)
668
+ })
669
+
670
+ it('should detect queue and event operations', () => {
671
+ const project = createTestProject({
672
+ '/src/events.ts': `
673
+ export async function processJob(job: any) {
674
+ const data = job.data
675
+ const result = computeResult(data)
676
+ const notification = { type: 'notification', data: result }
677
+
678
+ await queue.enqueue(notification)
679
+ emitter.emit('job-complete', { jobId: job.id, result })
680
+
681
+ return result
682
+ }
683
+
684
+ function computeResult(data: any) {
685
+ const processed = true
686
+ const result = { processed, data }
687
+ const final = result
688
+ return final
689
+ }
690
+ `,
691
+ })
692
+
693
+ const functions = analyzeSourceFile(project, '/src/events.ts')
694
+
695
+ const processJob = functions.find(f => f.name === 'processJob')
696
+ expect(processJob?.classification).toBe('impure')
697
+ expect(processJob?.markers.some(m => m.type === 'queue-enqueue')).toBe(
698
+ true,
699
+ )
700
+ expect(processJob?.markers.some(m => m.type === 'event-emit')).toBe(true)
701
+
702
+ const computeResult = functions.find(f => f.name === 'computeResult')
703
+ expect(computeResult?.classification).toBe('pure')
704
+ })
705
+ })
706
+ })