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,639 @@
1
+ /**
2
+ * Marker Detection Tests
3
+ *
4
+ * Tests for the pure core marker detection logic.
5
+ * These tests verify that impurity markers are correctly detected
6
+ * from extracted function data.
7
+ */
8
+
9
+ import { describe, it, expect } from 'vitest'
10
+
11
+ import {
12
+ detectMarkers,
13
+ createDetectionContext,
14
+ } from '../src/detection/detect-markers.js'
15
+ import type {
16
+ ExtractedFunction,
17
+ FileImports,
18
+ DetectionContext,
19
+ } from '../src/types.js'
20
+
21
+ /**
22
+ * Helper to create a minimal extracted function for testing
23
+ */
24
+ function createTestFunction(
25
+ overrides: Partial<ExtractedFunction> = {},
26
+ ): ExtractedFunction {
27
+ return {
28
+ name: 'testFunction',
29
+ filePath: '/test/file.ts',
30
+ startLine: 1,
31
+ endLine: 10,
32
+ isAsync: false,
33
+ isExported: false,
34
+ bodyLineCount: 10,
35
+ statementCount: 5,
36
+ hasConditionals: false,
37
+ parentContext: null,
38
+ callSites: [],
39
+ hasAwait: false,
40
+ propertyAccessChains: [],
41
+ kind: 'function',
42
+ ...overrides,
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Helper to create a minimal detection context
48
+ */
49
+ function createTestContext(
50
+ imports: FileImports['imports'] = [],
51
+ ): DetectionContext {
52
+ return createDetectionContext({
53
+ filePath: '/test/file.ts',
54
+ imports,
55
+ })
56
+ }
57
+
58
+ describe('detectMarkers', () => {
59
+ describe('await-expression detection', () => {
60
+ it('should detect await expressions', () => {
61
+ const fn = createTestFunction({
62
+ hasAwait: true,
63
+ callSites: [{ expression: 'fetchData', line: 5, isAwaited: true }],
64
+ })
65
+ const context = createTestContext()
66
+
67
+ const markers = detectMarkers(fn, context)
68
+
69
+ expect(markers).toContainEqual(
70
+ expect.objectContaining({
71
+ type: 'await-expression',
72
+ detail: expect.stringContaining('fetchData'),
73
+ }),
74
+ )
75
+ })
76
+
77
+ it('should not detect markers for async without await', () => {
78
+ const fn = createTestFunction({
79
+ isAsync: true,
80
+ hasAwait: false,
81
+ callSites: [{ expression: 'someFunction', line: 5, isAwaited: false }],
82
+ })
83
+ const context = createTestContext()
84
+
85
+ const markers = detectMarkers(fn, context)
86
+
87
+ expect(markers.filter(m => m.type === 'await-expression')).toHaveLength(0)
88
+ })
89
+
90
+ it('should detect multiple await expressions', () => {
91
+ const fn = createTestFunction({
92
+ hasAwait: true,
93
+ callSites: [
94
+ { expression: 'fetchUser', line: 2, isAwaited: true },
95
+ { expression: 'fetchPosts', line: 5, isAwaited: true },
96
+ { expression: 'transform', line: 8, isAwaited: false },
97
+ ],
98
+ })
99
+ const context = createTestContext()
100
+
101
+ const markers = detectMarkers(fn, context)
102
+ const awaitMarkers = markers.filter(m => m.type === 'await-expression')
103
+
104
+ expect(awaitMarkers).toHaveLength(2)
105
+ })
106
+ })
107
+
108
+ describe('database-call detection', () => {
109
+ it('should detect db.entity.operation calls', () => {
110
+ const fn = createTestFunction({
111
+ callSites: [
112
+ { expression: 'db.user.findFirst', line: 3, isAwaited: true },
113
+ ],
114
+ })
115
+ const context = createTestContext()
116
+
117
+ const markers = detectMarkers(fn, context)
118
+
119
+ expect(markers).toContainEqual(
120
+ expect.objectContaining({
121
+ type: 'database-call',
122
+ detail: 'db.user.findFirst',
123
+ }),
124
+ )
125
+ })
126
+
127
+ it('should detect prisma.entity.operation calls', () => {
128
+ const fn = createTestFunction({
129
+ callSites: [
130
+ { expression: 'prisma.post.create', line: 5, isAwaited: true },
131
+ ],
132
+ })
133
+ const context = createTestContext()
134
+
135
+ const markers = detectMarkers(fn, context)
136
+
137
+ expect(markers).toContainEqual(
138
+ expect.objectContaining({
139
+ type: 'database-call',
140
+ detail: 'prisma.post.create',
141
+ }),
142
+ )
143
+ })
144
+
145
+ it('should detect ctx.db.entity.operation calls', () => {
146
+ const fn = createTestFunction({
147
+ callSites: [
148
+ { expression: 'ctx.db.user.findMany', line: 3, isAwaited: true },
149
+ ],
150
+ })
151
+ const context = createTestContext()
152
+
153
+ const markers = detectMarkers(fn, context)
154
+
155
+ expect(markers).toContainEqual(
156
+ expect.objectContaining({
157
+ type: 'database-call',
158
+ detail: 'ctx.db.user.findMany',
159
+ }),
160
+ )
161
+ })
162
+
163
+ it('should detect various database operations', () => {
164
+ const operations = [
165
+ 'findFirst',
166
+ 'findMany',
167
+ 'findUnique',
168
+ 'create',
169
+ 'update',
170
+ 'delete',
171
+ 'upsert',
172
+ 'count',
173
+ 'aggregate',
174
+ 'groupBy',
175
+ ]
176
+
177
+ for (const op of operations) {
178
+ const fn = createTestFunction({
179
+ callSites: [
180
+ { expression: `db.user.${op}`, line: 1, isAwaited: false },
181
+ ],
182
+ })
183
+ const context = createTestContext()
184
+
185
+ const markers = detectMarkers(fn, context)
186
+
187
+ expect(markers).toContainEqual(
188
+ expect.objectContaining({
189
+ type: 'database-call',
190
+ }),
191
+ )
192
+ }
193
+ })
194
+
195
+ it('should not detect non-database calls', () => {
196
+ const fn = createTestFunction({
197
+ callSites: [
198
+ { expression: 'user.findFirst', line: 1, isAwaited: false },
199
+ { expression: 'array.find', line: 2, isAwaited: false },
200
+ ],
201
+ })
202
+ const context = createTestContext()
203
+
204
+ const markers = detectMarkers(fn, context)
205
+
206
+ expect(markers.filter(m => m.type === 'database-call')).toHaveLength(0)
207
+ })
208
+ })
209
+
210
+ describe('network-fetch detection', () => {
211
+ it('should detect fetch calls', () => {
212
+ const fn = createTestFunction({
213
+ callSites: [{ expression: 'fetch', line: 5, isAwaited: true }],
214
+ })
215
+ const context = createTestContext()
216
+
217
+ const markers = detectMarkers(fn, context)
218
+
219
+ expect(markers).toContainEqual(
220
+ expect.objectContaining({
221
+ type: 'network-fetch',
222
+ detail: 'fetch',
223
+ }),
224
+ )
225
+ })
226
+ })
227
+
228
+ describe('network-http detection', () => {
229
+ it('should detect axios calls', () => {
230
+ const fn = createTestFunction({
231
+ callSites: [{ expression: 'axios.get', line: 3, isAwaited: true }],
232
+ })
233
+ const context = createTestContext()
234
+
235
+ const markers = detectMarkers(fn, context)
236
+
237
+ expect(markers).toContainEqual(
238
+ expect.objectContaining({
239
+ type: 'network-http',
240
+ detail: 'axios.get',
241
+ }),
242
+ )
243
+ })
244
+
245
+ it('should NOT mark function as impure just because file imports axios', () => {
246
+ const fn = createTestFunction({
247
+ callSites: [
248
+ { expression: 'Object.entries', line: 2, isAwaited: false },
249
+ ],
250
+ })
251
+ const context = createTestContext([
252
+ { moduleSpecifier: 'axios', namedImports: ['axios'] },
253
+ ])
254
+
255
+ const markers = detectMarkers(fn, context)
256
+
257
+ expect(markers.filter(m => m.type === 'network-http')).toHaveLength(0)
258
+ })
259
+ })
260
+
261
+ describe('fs-call detection', () => {
262
+ it('should NOT mark function as impure just because file imports fs', () => {
263
+ const fn = createTestFunction({
264
+ callSites: [
265
+ { expression: 'Object.entries', line: 2, isAwaited: false },
266
+ ],
267
+ })
268
+ const context = createTestContext([
269
+ { moduleSpecifier: 'fs', namedImports: ['readFile'] },
270
+ ])
271
+
272
+ const markers = detectMarkers(fn, context)
273
+
274
+ expect(
275
+ markers.filter(m => m.type === 'fs-import' || m.type === 'fs-call'),
276
+ ).toHaveLength(0)
277
+ })
278
+
279
+ it('should detect fs.readFile calls', () => {
280
+ const fn = createTestFunction({
281
+ callSites: [{ expression: 'fs.readFile', line: 5, isAwaited: true }],
282
+ })
283
+ const context = createTestContext([
284
+ { moduleSpecifier: 'fs', namedImports: ['readFile'] },
285
+ ])
286
+
287
+ const markers = detectMarkers(fn, context)
288
+
289
+ expect(markers).toContainEqual(
290
+ expect.objectContaining({
291
+ type: 'fs-call',
292
+ detail: 'fs.readFile',
293
+ }),
294
+ )
295
+ })
296
+
297
+ it('should detect readFile calls', () => {
298
+ const fn = createTestFunction({
299
+ callSites: [{ expression: 'readFile', line: 5, isAwaited: true }],
300
+ })
301
+ const context = createTestContext([
302
+ { moduleSpecifier: 'fs/promises', namedImports: ['readFile'] },
303
+ ])
304
+
305
+ const markers = detectMarkers(fn, context)
306
+
307
+ expect(markers).toContainEqual(
308
+ expect.objectContaining({
309
+ type: 'fs-call',
310
+ detail: 'readFile',
311
+ }),
312
+ )
313
+ })
314
+ })
315
+
316
+ describe('env-access detection', () => {
317
+ it('should detect process.env access', () => {
318
+ const fn = createTestFunction({
319
+ propertyAccessChains: ['process.env.NODE_ENV'],
320
+ })
321
+ const context = createTestContext()
322
+
323
+ const markers = detectMarkers(fn, context)
324
+
325
+ expect(markers).toContainEqual(
326
+ expect.objectContaining({
327
+ type: 'env-access',
328
+ detail: 'process.env.NODE_ENV',
329
+ }),
330
+ )
331
+ })
332
+
333
+ it('should detect multiple env accesses', () => {
334
+ const fn = createTestFunction({
335
+ propertyAccessChains: [
336
+ 'process.env.NODE_ENV',
337
+ 'process.env.API_KEY',
338
+ 'process.env.DATABASE_URL',
339
+ ],
340
+ })
341
+ const context = createTestContext()
342
+
343
+ const markers = detectMarkers(fn, context)
344
+ const envMarkers = markers.filter(m => m.type === 'env-access')
345
+
346
+ expect(envMarkers).toHaveLength(3)
347
+ })
348
+ })
349
+
350
+ describe('console-log detection', () => {
351
+ it('should detect console.log calls', () => {
352
+ const fn = createTestFunction({
353
+ callSites: [{ expression: 'console.log', line: 5, isAwaited: false }],
354
+ })
355
+ const context = createTestContext()
356
+
357
+ const markers = detectMarkers(fn, context)
358
+
359
+ expect(markers).toContainEqual(
360
+ expect.objectContaining({
361
+ type: 'console-log',
362
+ detail: 'console.log',
363
+ }),
364
+ )
365
+ })
366
+
367
+ it('should detect console.error calls', () => {
368
+ const fn = createTestFunction({
369
+ callSites: [{ expression: 'console.error', line: 5, isAwaited: false }],
370
+ })
371
+ const context = createTestContext()
372
+
373
+ const markers = detectMarkers(fn, context)
374
+
375
+ expect(markers).toContainEqual(
376
+ expect.objectContaining({
377
+ type: 'console-log',
378
+ detail: 'console.error',
379
+ }),
380
+ )
381
+ })
382
+
383
+ it('should detect console.warn calls', () => {
384
+ const fn = createTestFunction({
385
+ callSites: [{ expression: 'console.warn', line: 5, isAwaited: false }],
386
+ })
387
+ const context = createTestContext()
388
+
389
+ const markers = detectMarkers(fn, context)
390
+
391
+ expect(markers).toContainEqual(
392
+ expect.objectContaining({
393
+ type: 'console-log',
394
+ }),
395
+ )
396
+ })
397
+ })
398
+
399
+ describe('logging detection', () => {
400
+ it('should detect logger calls', () => {
401
+ const fn = createTestFunction({
402
+ callSites: [{ expression: 'logger.info', line: 5, isAwaited: false }],
403
+ })
404
+ const context = createTestContext()
405
+
406
+ const markers = detectMarkers(fn, context)
407
+
408
+ expect(markers).toContainEqual(
409
+ expect.objectContaining({
410
+ type: 'logging',
411
+ detail: 'logger.info',
412
+ }),
413
+ )
414
+ })
415
+
416
+ it('should NOT mark function as impure just because file imports logger', () => {
417
+ const fn = createTestFunction({
418
+ callSites: [
419
+ { expression: 'Object.entries', line: 2, isAwaited: false },
420
+ ],
421
+ })
422
+ const context = createTestContext([
423
+ { moduleSpecifier: '@sai/logger', namedImports: ['log'] },
424
+ ])
425
+
426
+ const markers = detectMarkers(fn, context)
427
+
428
+ expect(markers.filter(m => m.type === 'logging')).toHaveLength(0)
429
+ })
430
+
431
+ it('should mark function as impure when it calls log()', () => {
432
+ const fn = createTestFunction({
433
+ callSites: [{ expression: 'log', line: 5, isAwaited: false }],
434
+ })
435
+ const context = createTestContext([
436
+ { moduleSpecifier: '@sai/logger', namedImports: ['log'] },
437
+ ])
438
+
439
+ const markers = detectMarkers(fn, context)
440
+
441
+ expect(markers).toContainEqual(
442
+ expect.objectContaining({ type: 'logging' }),
443
+ )
444
+ })
445
+ })
446
+
447
+ describe('telemetry detection', () => {
448
+ it('should detect trackEvent calls', () => {
449
+ const fn = createTestFunction({
450
+ callSites: [{ expression: 'trackEvent', line: 5, isAwaited: false }],
451
+ })
452
+ const context = createTestContext()
453
+
454
+ const markers = detectMarkers(fn, context)
455
+
456
+ expect(markers).toContainEqual(
457
+ expect.objectContaining({
458
+ type: 'telemetry',
459
+ detail: 'trackEvent',
460
+ }),
461
+ )
462
+ })
463
+
464
+ it('should detect analytics calls', () => {
465
+ const fn = createTestFunction({
466
+ callSites: [
467
+ { expression: 'analytics.track', line: 5, isAwaited: false },
468
+ ],
469
+ })
470
+ const context = createTestContext()
471
+
472
+ const markers = detectMarkers(fn, context)
473
+
474
+ expect(markers).toContainEqual(
475
+ expect.objectContaining({
476
+ type: 'telemetry',
477
+ }),
478
+ )
479
+ })
480
+ })
481
+
482
+ describe('queue-enqueue detection', () => {
483
+ it('should detect queue.enqueue calls', () => {
484
+ const fn = createTestFunction({
485
+ callSites: [{ expression: 'queue.enqueue', line: 5, isAwaited: false }],
486
+ })
487
+ const context = createTestContext()
488
+
489
+ const markers = detectMarkers(fn, context)
490
+
491
+ expect(markers).toContainEqual(
492
+ expect.objectContaining({
493
+ type: 'queue-enqueue',
494
+ detail: 'queue.enqueue',
495
+ }),
496
+ )
497
+ })
498
+
499
+ it('should detect jobQueue.add calls', () => {
500
+ const fn = createTestFunction({
501
+ callSites: [{ expression: 'jobQueue.add', line: 5, isAwaited: false }],
502
+ })
503
+ const context = createTestContext()
504
+
505
+ const markers = detectMarkers(fn, context)
506
+
507
+ expect(markers).toContainEqual(
508
+ expect.objectContaining({
509
+ type: 'queue-enqueue',
510
+ }),
511
+ )
512
+ })
513
+ })
514
+
515
+ describe('event-emit detection', () => {
516
+ it('should detect emit calls', () => {
517
+ const fn = createTestFunction({
518
+ callSites: [{ expression: 'emitter.emit', line: 5, isAwaited: false }],
519
+ })
520
+ const context = createTestContext()
521
+
522
+ const markers = detectMarkers(fn, context)
523
+
524
+ expect(markers).toContainEqual(
525
+ expect.objectContaining({
526
+ type: 'event-emit',
527
+ detail: 'emitter.emit',
528
+ }),
529
+ )
530
+ })
531
+
532
+ it('should detect dispatch calls', () => {
533
+ const fn = createTestFunction({
534
+ callSites: [
535
+ { expression: 'eventBus.dispatch', line: 5, isAwaited: false },
536
+ ],
537
+ })
538
+ const context = createTestContext()
539
+
540
+ const markers = detectMarkers(fn, context)
541
+
542
+ expect(markers).toContainEqual(
543
+ expect.objectContaining({
544
+ type: 'event-emit',
545
+ }),
546
+ )
547
+ })
548
+ })
549
+
550
+ describe('pure functions', () => {
551
+ it('should return no markers for a pure function', () => {
552
+ const fn = createTestFunction({
553
+ name: 'calculateSum',
554
+ callSites: [
555
+ { expression: 'Math.max', line: 2, isAwaited: false },
556
+ { expression: 'array.reduce', line: 3, isAwaited: false },
557
+ ],
558
+ propertyAccessChains: ['Math.PI'],
559
+ })
560
+ const context = createTestContext()
561
+
562
+ const markers = detectMarkers(fn, context)
563
+
564
+ expect(markers).toHaveLength(0)
565
+ })
566
+
567
+ it('should return no markers for async function without I/O', () => {
568
+ const fn = createTestFunction({
569
+ name: 'asyncPureFunction',
570
+ isAsync: true,
571
+ hasAwait: false,
572
+ callSites: [{ expression: 'transform', line: 2, isAwaited: false }],
573
+ })
574
+ const context = createTestContext()
575
+
576
+ const markers = detectMarkers(fn, context)
577
+
578
+ expect(markers).toHaveLength(0)
579
+ })
580
+ })
581
+
582
+ describe('multiple markers', () => {
583
+ it('should detect multiple different marker types', () => {
584
+ const fn = createTestFunction({
585
+ hasAwait: true,
586
+ callSites: [
587
+ { expression: 'db.user.findFirst', line: 2, isAwaited: true },
588
+ { expression: 'fetch', line: 5, isAwaited: true },
589
+ { expression: 'console.log', line: 8, isAwaited: false },
590
+ ],
591
+ propertyAccessChains: ['process.env.API_URL'],
592
+ })
593
+ const context = createTestContext()
594
+
595
+ const markers = detectMarkers(fn, context)
596
+
597
+ const markerTypes = new Set(markers.map(m => m.type))
598
+
599
+ expect(markerTypes.has('await-expression')).toBe(true)
600
+ expect(markerTypes.has('database-call')).toBe(true)
601
+ expect(markerTypes.has('network-fetch')).toBe(true)
602
+ expect(markerTypes.has('console-log')).toBe(true)
603
+ expect(markerTypes.has('env-access')).toBe(true)
604
+ })
605
+ })
606
+ })
607
+
608
+ describe('createDetectionContext', () => {
609
+ it('should identify pure file imports', () => {
610
+ const imports: FileImports = {
611
+ filePath: '/test/file.ts',
612
+ imports: [
613
+ { moduleSpecifier: './utils.pure', namedImports: ['calculateTotal'] },
614
+ { moduleSpecifier: './helpers', namedImports: ['formatDate'] },
615
+ ],
616
+ }
617
+
618
+ const context = createDetectionContext(imports)
619
+
620
+ expect(context.pureFileImports.has('./utils.pure')).toBe(true)
621
+ expect(context.pureFileImports.has('./helpers')).toBe(false)
622
+ })
623
+
624
+ it('should handle imports from .pure subdirectories', () => {
625
+ const imports: FileImports = {
626
+ filePath: '/test/file.ts',
627
+ imports: [
628
+ {
629
+ moduleSpecifier: './domain.pure/calculations',
630
+ namedImports: ['compute'],
631
+ },
632
+ ],
633
+ }
634
+
635
+ const context = createDetectionContext(imports)
636
+
637
+ expect(context.pureFileImports.has('./domain.pure/calculations')).toBe(true)
638
+ })
639
+ })