@clayroach/effect-unplugin 4.0.0-effect4-transformer.1

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.
@@ -0,0 +1,667 @@
1
+ /**
2
+ * Core source trace transformer for Effect.gen yield* expressions.
3
+ *
4
+ * @since 0.0.1
5
+ */
6
+ import { parse } from "@babel/parser"
7
+ import * as _traverse from "@babel/traverse"
8
+ import * as _generate from "@babel/generator"
9
+ import * as t from "@babel/types"
10
+ import type {
11
+ InstrumentableEffect,
12
+ SourceTraceOptions,
13
+ SpanInstrumentationOptions,
14
+ SpanNameFormat
15
+ } from "./types.ts"
16
+
17
+ type NodePath<T = t.Node> = _traverse.NodePath<T>
18
+
19
+ type GenerateFn = (
20
+ ast: t.Node,
21
+ opts: _generate.GeneratorOptions,
22
+ code?: string | { [filename: string]: string }
23
+ ) => _generate.GeneratorResult
24
+
25
+ type TraverseFn = (ast: t.Node, opts: _traverse.TraverseOptions) => void
26
+
27
+ // Handle CommonJS/ESM interop for babel packages
28
+ // Babel packages can be imported as ESM or CJS with varying module structures
29
+ type BabelModule<T> =
30
+ | T
31
+ | { default: T }
32
+ | { default: { default: T } }
33
+
34
+ function extractBabelExport<T>(module: BabelModule<T>): T {
35
+ // Check if module is directly the export (ESM)
36
+ if (typeof module === "function") {
37
+ return module
38
+ }
39
+ // Check for CJS default export
40
+ const moduleAsRecord = module as Record<string, unknown>
41
+ if (moduleAsRecord.default !== undefined) {
42
+ if (typeof moduleAsRecord.default === "function") {
43
+ return moduleAsRecord.default as T
44
+ }
45
+ // Check for double-wrapped default (CJS -> ESM -> CJS)
46
+ const defaultAsRecord = moduleAsRecord.default as Record<string, unknown>
47
+ if (defaultAsRecord?.default !== undefined) {
48
+ return defaultAsRecord.default as T
49
+ }
50
+ }
51
+ return module as T
52
+ }
53
+
54
+ const traverse: TraverseFn = extractBabelExport<TraverseFn>(_traverse as BabelModule<TraverseFn>)
55
+ const generate: GenerateFn = extractBabelExport<GenerateFn>(_generate as BabelModule<GenerateFn>)
56
+
57
+ interface StackFrameInfo {
58
+ readonly name: string
59
+ readonly location: string
60
+ readonly varName: string
61
+ }
62
+
63
+ interface SpanAttributes {
64
+ readonly filepath: string
65
+ readonly line: number
66
+ readonly column: number
67
+ readonly functionName: string
68
+ }
69
+
70
+ const ALL_INSTRUMENTABLE: ReadonlyArray<InstrumentableEffect> = [
71
+ "gen", "fork", "forkDaemon", "forkScoped", "all", "forEach", "filter", "reduce", "iterate", "loop"
72
+ ]
73
+
74
+ /**
75
+ * Resolves which Effect combinators should be instrumented with spans.
76
+ */
77
+ function resolveInstrumentable(options: SpanInstrumentationOptions): Set<string> {
78
+ const include = options.include ?? ALL_INSTRUMENTABLE
79
+ const exclude = new Set(options.exclude ?? [])
80
+ return new Set(include.filter((name) => !exclude.has(name)))
81
+ }
82
+
83
+ /**
84
+ * Simple glob matcher for common patterns.
85
+ * Supports: *, **, path/to/file
86
+ * Normalizes paths by removing leading slashes for matching.
87
+ */
88
+ function matchesGlob(filepath: string, pattern: string): boolean {
89
+ const normalizedPath = filepath.replace(/^\/+/, "")
90
+ const regexPattern = pattern
91
+ .replace(/\./g, "\\.")
92
+ .replace(/\*\*/g, ".*")
93
+ .replace(/\*/g, "[^/]*")
94
+ const regex = new RegExp(`^${regexPattern}$`)
95
+ return regex.test(normalizedPath)
96
+ }
97
+
98
+ /**
99
+ * Checks if a file matches any of the given patterns.
100
+ */
101
+ function matchesAnyPattern(filepath: string, patterns: string | ReadonlyArray<string>): boolean {
102
+ const patternArray = Array.isArray(patterns) ? patterns : [patterns]
103
+ return patternArray.some((pattern) => matchesGlob(filepath, pattern))
104
+ }
105
+
106
+ /**
107
+ * Checks if a function name matches any regex pattern.
108
+ */
109
+ function matchesAnyRegex(functionName: string, patterns: string | ReadonlyArray<string>): boolean {
110
+ const patternArray = Array.isArray(patterns) ? patterns : [patterns]
111
+ return patternArray.some((pattern) => new RegExp(pattern).test(functionName))
112
+ }
113
+
114
+ /**
115
+ * Checks if instrumentation should be applied based on strategy.
116
+ */
117
+ function shouldInstrument(
118
+ combinator: InstrumentableEffect,
119
+ filepath: string,
120
+ functionName: string | null,
121
+ depth: number,
122
+ options: SpanInstrumentationOptions
123
+ ): boolean {
124
+ const strategy = options.strategy
125
+
126
+ if (!strategy) {
127
+ return true // No strategy = instrument everything
128
+ }
129
+
130
+ if (strategy.type === "depth") {
131
+ const globalMax = strategy.maxDepth ?? Infinity
132
+ const combinatorMax = strategy.perCombinator?.[combinator]
133
+ const effectiveMax = combinatorMax !== undefined ? combinatorMax : globalMax
134
+ return depth <= effectiveMax
135
+ }
136
+
137
+ if (strategy.type === "overrides") {
138
+ const filter = strategy.rules[combinator]
139
+ if (!filter) {
140
+ return true // No override for this combinator = allow
141
+ }
142
+
143
+ // Check file filters
144
+ if (filter.files && !matchesAnyPattern(filepath, filter.files)) {
145
+ return false
146
+ }
147
+ if (filter.excludeFiles && matchesAnyPattern(filepath, filter.excludeFiles)) {
148
+ return false
149
+ }
150
+
151
+ // Check function filters
152
+ if (functionName) {
153
+ if (filter.functions && !matchesAnyRegex(functionName, filter.functions)) {
154
+ return false
155
+ }
156
+ if (filter.excludeFunctions && matchesAnyRegex(functionName, filter.excludeFunctions)) {
157
+ return false
158
+ }
159
+ }
160
+
161
+ return true
162
+ }
163
+
164
+ return true
165
+ }
166
+
167
+ /**
168
+ * @since 0.0.1
169
+ * @category models
170
+ */
171
+ export interface TransformResult {
172
+ readonly code: string
173
+ readonly map?: unknown
174
+ readonly transformed: boolean
175
+ }
176
+
177
+ /**
178
+ * Determines if a call expression is Effect.gen() or a named import gen().
179
+ */
180
+ function isEffectGenCall(
181
+ node: t.CallExpression,
182
+ effectImportName: string | null,
183
+ genImportName: string | null
184
+ ): boolean {
185
+ const callee = node.callee
186
+
187
+ // Effect.gen(...)
188
+ if (
189
+ t.isMemberExpression(callee) &&
190
+ t.isIdentifier(callee.object) &&
191
+ callee.object.name === effectImportName &&
192
+ t.isIdentifier(callee.property) &&
193
+ callee.property.name === "gen"
194
+ ) {
195
+ return true
196
+ }
197
+
198
+ // gen(...) from named import
199
+ if (t.isIdentifier(callee) && callee.name === genImportName) {
200
+ return true
201
+ }
202
+
203
+ return false
204
+ }
205
+
206
+ /**
207
+ * Extracts function name from a yield* argument expression.
208
+ */
209
+ function extractFunctionName(node: t.Expression): string {
210
+ // foo()
211
+ if (t.isCallExpression(node) && t.isIdentifier(node.callee)) {
212
+ return node.callee.name
213
+ }
214
+
215
+ // obj.method()
216
+ if (
217
+ t.isCallExpression(node) &&
218
+ t.isMemberExpression(node.callee) &&
219
+ t.isIdentifier(node.callee.property)
220
+ ) {
221
+ return node.callee.property.name
222
+ }
223
+
224
+ // Effect.succeed(...)
225
+ if (
226
+ t.isCallExpression(node) &&
227
+ t.isMemberExpression(node.callee) &&
228
+ t.isIdentifier(node.callee.object) &&
229
+ t.isIdentifier(node.callee.property)
230
+ ) {
231
+ return `${node.callee.object.name}.${node.callee.property.name}`
232
+ }
233
+
234
+ return "unknown"
235
+ }
236
+
237
+ /**
238
+ * Creates a hoisted StackFrame variable declaration.
239
+ */
240
+ function createStackFrameDeclaration(info: StackFrameInfo): t.VariableDeclaration {
241
+ return t.variableDeclaration("const", [
242
+ t.variableDeclarator(
243
+ t.identifier(info.varName),
244
+ t.objectExpression([
245
+ t.objectProperty(t.identifier("name"), t.stringLiteral(info.name)),
246
+ t.objectProperty(
247
+ t.identifier("stack"),
248
+ t.arrowFunctionExpression([], t.stringLiteral(info.location))
249
+ ),
250
+ t.objectProperty(t.identifier("parent"), t.identifier("undefined"))
251
+ ])
252
+ )
253
+ ])
254
+ }
255
+
256
+ /**
257
+ * Wraps a yield* argument with Effect.updateService.
258
+ */
259
+ function wrapWithUpdateService(
260
+ argument: t.Expression,
261
+ frameVarName: string,
262
+ effectImportName: string,
263
+ referencesImportName: string
264
+ ): t.CallExpression {
265
+ return t.callExpression(
266
+ t.memberExpression(t.identifier(effectImportName), t.identifier("updateService")),
267
+ [
268
+ argument,
269
+ t.memberExpression(t.identifier(referencesImportName), t.identifier("CurrentStackFrame")),
270
+ t.arrowFunctionExpression(
271
+ [t.identifier("parent")],
272
+ t.objectExpression([
273
+ t.spreadElement(t.identifier(frameVarName)),
274
+ t.objectProperty(t.identifier("parent"), t.identifier("parent"))
275
+ ])
276
+ )
277
+ ]
278
+ )
279
+ }
280
+
281
+ /**
282
+ * Gets the variable/function name from the AST context.
283
+ * Handles multiple patterns: direct assignment, arrow functions, object properties, function declarations.
284
+ */
285
+ function getAssignedVariableName(path: NodePath<t.CallExpression>): string | null {
286
+ const parent = path.parent
287
+
288
+ // Case 1: const program = Effect.gen(...)
289
+ if (t.isVariableDeclarator(parent) && t.isIdentifier(parent.id)) {
290
+ return parent.id.name
291
+ }
292
+
293
+ // Case 2: const fetchUser = (id) => Effect.gen(...)
294
+ if (t.isArrowFunctionExpression(parent)) {
295
+ const grandparent = path.parentPath?.parent
296
+ if (t.isVariableDeclarator(grandparent) && t.isIdentifier(grandparent.id)) {
297
+ return grandparent.id.name
298
+ }
299
+ }
300
+
301
+ // Case 3: { fetchUser: Effect.gen(...) }
302
+ if (t.isObjectProperty(parent) && t.isIdentifier(parent.key)) {
303
+ return parent.key.name
304
+ }
305
+
306
+ // Case 4: function fetchUser() { return Effect.gen(...) }
307
+ if (t.isReturnStatement(parent)) {
308
+ const funcParent = path.parentPath?.parentPath?.parent
309
+ if (t.isFunctionDeclaration(funcParent) && funcParent.id) {
310
+ return funcParent.id.name
311
+ }
312
+ }
313
+
314
+ return null
315
+ }
316
+
317
+ /**
318
+ * Formats span name based on the nameFormat option.
319
+ */
320
+ function formatSpanName(
321
+ combinator: string,
322
+ functionName: string | null,
323
+ filename: string,
324
+ line: number,
325
+ format: SpanNameFormat
326
+ ): string {
327
+ switch (format) {
328
+ case "function":
329
+ return functionName
330
+ ? `${combinator} (${functionName})`
331
+ : combinator
332
+
333
+ case "location":
334
+ return `${combinator} (${filename}:${line})`
335
+
336
+ case "full":
337
+ return functionName
338
+ ? `${combinator} (${functionName} @ ${filename}:${line})`
339
+ : `${combinator} (${filename}:${line})`
340
+ }
341
+ }
342
+
343
+ /**
344
+ * Wraps an expression with Effect.withSpan() and attributes.
345
+ */
346
+ function wrapWithSpan(
347
+ expr: t.Expression,
348
+ spanName: string,
349
+ effectImportName: string,
350
+ attrs: SpanAttributes
351
+ ): t.CallExpression {
352
+ const attributesObject = t.objectExpression([
353
+ t.objectProperty(t.stringLiteral("code.filepath"), t.stringLiteral(attrs.filepath)),
354
+ t.objectProperty(t.stringLiteral("code.lineno"), t.numericLiteral(attrs.line)),
355
+ t.objectProperty(t.stringLiteral("code.column"), t.numericLiteral(attrs.column)),
356
+ t.objectProperty(t.stringLiteral("code.function"), t.stringLiteral(attrs.functionName))
357
+ ])
358
+
359
+ const optionsObject = t.objectExpression([
360
+ t.objectProperty(t.identifier("attributes"), attributesObject)
361
+ ])
362
+
363
+ return t.callExpression(
364
+ t.memberExpression(t.identifier(effectImportName), t.identifier("withSpan")),
365
+ [expr, t.stringLiteral(spanName), optionsObject]
366
+ )
367
+ }
368
+
369
+ /**
370
+ * Finds or creates the Effect namespace import name.
371
+ */
372
+ function findOrGetEffectImport(ast: t.File): { effectName: string; genName: string | null } {
373
+ let effectName: string | null = null
374
+ let genName: string | null = null
375
+
376
+ for (const node of ast.program.body) {
377
+ if (!t.isImportDeclaration(node)) continue
378
+ if (node.source.value !== "effect") continue
379
+
380
+ for (const specifier of node.specifiers) {
381
+ // import * as Effect from "effect" or import { Effect } from "effect"
382
+ if (t.isImportNamespaceSpecifier(specifier)) {
383
+ effectName = specifier.local.name
384
+ } else if (t.isImportSpecifier(specifier)) {
385
+ const imported = t.isIdentifier(specifier.imported)
386
+ ? specifier.imported.name
387
+ : specifier.imported.value
388
+ if (imported === "Effect") {
389
+ effectName = specifier.local.name
390
+ } else if (imported === "gen") {
391
+ genName = specifier.local.name
392
+ }
393
+ }
394
+ }
395
+ }
396
+
397
+ return { effectName: effectName ?? "Effect", genName }
398
+ }
399
+
400
+ /**
401
+ * Ensures References is imported from effect.
402
+ */
403
+ function ensureReferencesImport(ast: t.File): string {
404
+ for (const node of ast.program.body) {
405
+ if (!t.isImportDeclaration(node)) continue
406
+ if (node.source.value !== "effect") continue
407
+
408
+ for (const specifier of node.specifiers) {
409
+ if (t.isImportSpecifier(specifier)) {
410
+ const imported = t.isIdentifier(specifier.imported)
411
+ ? specifier.imported.name
412
+ : specifier.imported.value
413
+ if (imported === "References") {
414
+ return specifier.local.name
415
+ }
416
+ }
417
+ }
418
+
419
+ // Add References to existing effect import
420
+ node.specifiers.push(
421
+ t.importSpecifier(t.identifier("References"), t.identifier("References"))
422
+ )
423
+ return "References"
424
+ }
425
+
426
+ // No effect import found - add one (unlikely scenario)
427
+ const importDecl = t.importDeclaration(
428
+ [t.importSpecifier(t.identifier("References"), t.identifier("References"))],
429
+ t.stringLiteral("effect")
430
+ )
431
+ ast.program.body.unshift(importDecl)
432
+ return "References"
433
+ }
434
+
435
+ /**
436
+ * Extracts the filename from a full path.
437
+ */
438
+ function getFileName(filePath: string): string {
439
+ const parts = filePath.split("/")
440
+ return parts[parts.length - 1] ?? filePath
441
+ }
442
+
443
+ /**
444
+ * Transforms source code to inject source location tracing and span instrumentation.
445
+ *
446
+ * @since 0.0.1
447
+ * @category transform
448
+ */
449
+ export function transform(
450
+ code: string,
451
+ id: string,
452
+ options: SourceTraceOptions = {}
453
+ ): TransformResult {
454
+ const enableSourceTrace = options.sourceTrace !== false
455
+ const extractFnName = options.extractFunctionName !== false
456
+ const spanOptions = options.spans
457
+ const enableSpans = spanOptions?.enabled === true
458
+
459
+ let ast: t.File
460
+ try {
461
+ ast = parse(code, {
462
+ sourceType: "module",
463
+ plugins: ["typescript", "jsx"],
464
+ sourceFilename: id
465
+ })
466
+ } catch {
467
+ return { code, transformed: false }
468
+ }
469
+
470
+ const { effectName, genName } = findOrGetEffectImport(ast)
471
+
472
+ // No Effect import found
473
+ if (!effectName && !genName) {
474
+ return { code, transformed: false }
475
+ }
476
+
477
+ let hasTransformed = false
478
+ const fileName = getFileName(id)
479
+
480
+ // Span instrumentation pass
481
+ if (enableSpans && effectName) {
482
+ const instrumentable = resolveInstrumentable(spanOptions!)
483
+ const nameFormat = spanOptions!.nameFormat ?? "function"
484
+ const wrappedNodes = new WeakSet<t.CallExpression>()
485
+ const depthMap = new WeakMap<t.Node, number>()
486
+
487
+ // Calculate depth for each node
488
+ traverse(ast, {
489
+ CallExpression(path: NodePath<t.CallExpression>) {
490
+ const callee = path.node.callee
491
+ if (
492
+ t.isMemberExpression(callee) &&
493
+ t.isIdentifier(callee.object) &&
494
+ callee.object.name === effectName &&
495
+ t.isIdentifier(callee.property) &&
496
+ instrumentable.has(callee.property.name)
497
+ ) {
498
+ // Find parent Effect call depth
499
+ let depth = 0
500
+ let currentPath: NodePath | null = path.parentPath
501
+ while (currentPath) {
502
+ const node = currentPath.node
503
+ if (t.isCallExpression(node) && depthMap.has(node)) {
504
+ depth = (depthMap.get(node) ?? 0) + 1
505
+ break
506
+ }
507
+ currentPath = currentPath.parentPath
508
+ }
509
+ depthMap.set(path.node, depth)
510
+ }
511
+ }
512
+ })
513
+
514
+ // Instrument nodes that pass filters
515
+ traverse(ast, {
516
+ CallExpression(path: NodePath<t.CallExpression>) {
517
+ if (wrappedNodes.has(path.node)) return
518
+
519
+ const callee = path.node.callee
520
+ if (!t.isMemberExpression(callee)) return
521
+ if (!t.isIdentifier(callee.object) || callee.object.name !== effectName) return
522
+ if (!t.isIdentifier(callee.property)) return
523
+
524
+ const methodName = callee.property.name
525
+ if (!instrumentable.has(methodName)) return
526
+
527
+ const loc = path.node.loc
528
+ if (!loc) return
529
+
530
+ const variableName = getAssignedVariableName(path)
531
+ const depth = depthMap.get(path.node) ?? 0
532
+
533
+ // Check if instrumentation should be applied
534
+ if (!shouldInstrument(methodName as InstrumentableEffect, id, variableName, depth, spanOptions!)) {
535
+ return
536
+ }
537
+
538
+ const combinator = `effect.${methodName}`
539
+ const functionName = variableName ?? combinator
540
+ const spanName = formatSpanName(combinator, variableName, fileName, loc.start.line, nameFormat)
541
+ const attrs: SpanAttributes = {
542
+ filepath: id,
543
+ line: loc.start.line,
544
+ column: loc.start.column,
545
+ functionName
546
+ }
547
+
548
+ const wrapped = wrapWithSpan(path.node, spanName, effectName, attrs)
549
+ wrappedNodes.add(path.node)
550
+ path.replaceWith(wrapped)
551
+ hasTransformed = true
552
+ }
553
+ })
554
+ }
555
+
556
+ // Source trace pass
557
+ if (enableSourceTrace) {
558
+ const framesByLocation = new Map<string, StackFrameInfo>()
559
+ let frameCounter = 0
560
+
561
+ // First pass: collect all yield* locations and create frame info
562
+ traverse(ast, {
563
+ CallExpression(path: NodePath<t.CallExpression>) {
564
+ if (!isEffectGenCall(path.node, effectName, genName)) return
565
+
566
+ const generatorArg = path.node.arguments[0]
567
+ if (!t.isFunctionExpression(generatorArg) || !generatorArg.generator) return
568
+
569
+ path.traverse({
570
+ // Skip nested Effect.gen calls to avoid processing their yield* twice
571
+ CallExpression(nestedPath: NodePath<t.CallExpression>) {
572
+ if (isEffectGenCall(nestedPath.node, effectName, genName)) {
573
+ nestedPath.skip()
574
+ }
575
+ },
576
+ YieldExpression(yieldPath: NodePath<t.YieldExpression>) {
577
+ // Only process yield* (delegate)
578
+ if (!yieldPath.node.delegate || !yieldPath.node.argument) return
579
+
580
+ const loc = yieldPath.node.loc
581
+ if (!loc) return
582
+
583
+ const location = `${id}:${loc.start.line}:${loc.start.column}`
584
+
585
+ if (!framesByLocation.has(location)) {
586
+ const name = extractFnName
587
+ ? extractFunctionName(yieldPath.node.argument)
588
+ : "effect"
589
+ const varName = `_sf${frameCounter++}`
590
+ const info: StackFrameInfo = { name, location, varName }
591
+ framesByLocation.set(location, info)
592
+ }
593
+ }
594
+ })
595
+ }
596
+ })
597
+
598
+ if (framesByLocation.size > 0) {
599
+ const referencesName = ensureReferencesImport(ast)
600
+
601
+ // Second pass: wrap yield* expressions
602
+ traverse(ast, {
603
+ CallExpression(path: NodePath<t.CallExpression>) {
604
+ if (!isEffectGenCall(path.node, effectName, genName)) return
605
+
606
+ const generatorArg = path.node.arguments[0]
607
+ if (!t.isFunctionExpression(generatorArg) || !generatorArg.generator) return
608
+
609
+ path.traverse({
610
+ // Skip nested Effect.gen calls to avoid processing their yield* twice
611
+ CallExpression(nestedPath: NodePath<t.CallExpression>) {
612
+ if (isEffectGenCall(nestedPath.node, effectName, genName)) {
613
+ nestedPath.skip()
614
+ }
615
+ },
616
+ YieldExpression(yieldPath: NodePath<t.YieldExpression>) {
617
+ if (!yieldPath.node.delegate || !yieldPath.node.argument) return
618
+
619
+ const loc = yieldPath.node.loc
620
+ if (!loc) return
621
+
622
+ const location = `${id}:${loc.start.line}:${loc.start.column}`
623
+ const frame = framesByLocation.get(location)
624
+ if (!frame) return
625
+
626
+ const wrapped = wrapWithUpdateService(
627
+ yieldPath.node.argument,
628
+ frame.varName,
629
+ effectName!,
630
+ referencesName
631
+ )
632
+ yieldPath.node.argument = wrapped
633
+ hasTransformed = true
634
+ }
635
+ })
636
+ }
637
+ })
638
+
639
+ // Insert hoisted frame declarations after imports
640
+ const frameDeclarations = Array.from(framesByLocation.values()).map(createStackFrameDeclaration)
641
+ let insertIndex = 0
642
+ for (let i = 0; i < ast.program.body.length; i++) {
643
+ if (!t.isImportDeclaration(ast.program.body[i])) {
644
+ insertIndex = i
645
+ break
646
+ }
647
+ insertIndex = i + 1
648
+ }
649
+ ast.program.body.splice(insertIndex, 0, ...frameDeclarations)
650
+ }
651
+ }
652
+
653
+ if (!hasTransformed) {
654
+ return { code, transformed: false }
655
+ }
656
+
657
+ const result = generate(ast, {
658
+ sourceMaps: true,
659
+ sourceFileName: id
660
+ }, code)
661
+
662
+ return {
663
+ code: result.code,
664
+ map: result.map,
665
+ transformed: true
666
+ }
667
+ }