@clayroach/effect-unplugin 4.0.0-source-tracing.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.
@@ -0,0 +1,447 @@
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 { InstrumentableEffect, SourceTraceOptions, SpanInstrumentationOptions } from "./types.ts"
11
+
12
+ type NodePath<T = t.Node> = _traverse.NodePath<T>
13
+
14
+ type GenerateFn = (
15
+ ast: t.Node,
16
+ opts: _generate.GeneratorOptions,
17
+ code?: string | { [filename: string]: string }
18
+ ) => _generate.GeneratorResult
19
+
20
+ // Handle CommonJS default exports - runtime interop
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ const traverse: (ast: t.Node, opts: _traverse.TraverseOptions) => void = (_traverse as any).default ?? _traverse
23
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
24
+ const generate: GenerateFn = (_generate as any).default ?? _generate
25
+
26
+ interface StackFrameInfo {
27
+ readonly name: string
28
+ readonly location: string
29
+ readonly varName: string
30
+ }
31
+
32
+ const ALL_INSTRUMENTABLE: ReadonlyArray<InstrumentableEffect> = [
33
+ "gen", "fork", "forkDaemon", "forkScoped", "all", "forEach", "filter", "reduce", "iterate", "loop"
34
+ ]
35
+
36
+ /**
37
+ * Resolves which Effect combinators should be instrumented with spans.
38
+ */
39
+ function resolveInstrumentable(options: SpanInstrumentationOptions): Set<string> {
40
+ const include = options.include ?? ALL_INSTRUMENTABLE
41
+ const exclude = new Set(options.exclude ?? [])
42
+ return new Set(include.filter((name) => !exclude.has(name)))
43
+ }
44
+
45
+ /**
46
+ * @since 0.0.1
47
+ * @category models
48
+ */
49
+ export interface TransformResult {
50
+ readonly code: string
51
+ readonly map?: unknown
52
+ readonly transformed: boolean
53
+ }
54
+
55
+ /**
56
+ * Determines if a call expression is Effect.gen() or a named import gen().
57
+ */
58
+ function isEffectGenCall(
59
+ node: t.CallExpression,
60
+ effectImportName: string | null,
61
+ genImportName: string | null
62
+ ): boolean {
63
+ const callee = node.callee
64
+
65
+ // Effect.gen(...)
66
+ if (
67
+ t.isMemberExpression(callee) &&
68
+ t.isIdentifier(callee.object) &&
69
+ callee.object.name === effectImportName &&
70
+ t.isIdentifier(callee.property) &&
71
+ callee.property.name === "gen"
72
+ ) {
73
+ return true
74
+ }
75
+
76
+ // gen(...) from named import
77
+ if (t.isIdentifier(callee) && callee.name === genImportName) {
78
+ return true
79
+ }
80
+
81
+ return false
82
+ }
83
+
84
+ /**
85
+ * Extracts function name from a yield* argument expression.
86
+ */
87
+ function extractFunctionName(node: t.Expression): string {
88
+ // foo()
89
+ if (t.isCallExpression(node) && t.isIdentifier(node.callee)) {
90
+ return node.callee.name
91
+ }
92
+
93
+ // obj.method()
94
+ if (
95
+ t.isCallExpression(node) &&
96
+ t.isMemberExpression(node.callee) &&
97
+ t.isIdentifier(node.callee.property)
98
+ ) {
99
+ return node.callee.property.name
100
+ }
101
+
102
+ // Effect.succeed(...)
103
+ if (
104
+ t.isCallExpression(node) &&
105
+ t.isMemberExpression(node.callee) &&
106
+ t.isIdentifier(node.callee.object) &&
107
+ t.isIdentifier(node.callee.property)
108
+ ) {
109
+ return `${node.callee.object.name}.${node.callee.property.name}`
110
+ }
111
+
112
+ return "unknown"
113
+ }
114
+
115
+ /**
116
+ * Creates a hoisted StackFrame variable declaration.
117
+ */
118
+ function createStackFrameDeclaration(info: StackFrameInfo): t.VariableDeclaration {
119
+ return t.variableDeclaration("const", [
120
+ t.variableDeclarator(
121
+ t.identifier(info.varName),
122
+ t.objectExpression([
123
+ t.objectProperty(t.identifier("name"), t.stringLiteral(info.name)),
124
+ t.objectProperty(
125
+ t.identifier("stack"),
126
+ t.arrowFunctionExpression([], t.stringLiteral(info.location))
127
+ ),
128
+ t.objectProperty(t.identifier("parent"), t.identifier("undefined"))
129
+ ])
130
+ )
131
+ ])
132
+ }
133
+
134
+ /**
135
+ * Wraps a yield* argument with Effect.updateService.
136
+ */
137
+ function wrapWithUpdateService(
138
+ argument: t.Expression,
139
+ frameVarName: string,
140
+ effectImportName: string,
141
+ referencesImportName: string
142
+ ): t.CallExpression {
143
+ return t.callExpression(
144
+ t.memberExpression(t.identifier(effectImportName), t.identifier("updateService")),
145
+ [
146
+ argument,
147
+ t.memberExpression(t.identifier(referencesImportName), t.identifier("CurrentStackFrame")),
148
+ t.arrowFunctionExpression(
149
+ [t.identifier("parent")],
150
+ t.objectExpression([
151
+ t.spreadElement(t.identifier(frameVarName)),
152
+ t.objectProperty(t.identifier("parent"), t.identifier("parent"))
153
+ ])
154
+ )
155
+ ]
156
+ )
157
+ }
158
+
159
+ /**
160
+ * Gets the variable name from a variable declarator parent.
161
+ */
162
+ function getAssignedVariableName(path: NodePath<t.CallExpression>): string | null {
163
+ const parent = path.parent
164
+ if (t.isVariableDeclarator(parent) && t.isIdentifier(parent.id)) {
165
+ return parent.id.name
166
+ }
167
+ return null
168
+ }
169
+
170
+ /**
171
+ * Creates a span name from variable name or location.
172
+ */
173
+ function createSpanName(variableName: string | null, fileName: string, line: number): string {
174
+ if (variableName) {
175
+ return `${variableName} (${fileName}:${line})`
176
+ }
177
+ return `${fileName}:${line}`
178
+ }
179
+
180
+ /**
181
+ * Wraps an expression with Effect.withSpan().
182
+ */
183
+ function wrapWithSpan(
184
+ expr: t.Expression,
185
+ spanName: string,
186
+ effectImportName: string
187
+ ): t.CallExpression {
188
+ return t.callExpression(
189
+ t.memberExpression(t.identifier(effectImportName), t.identifier("withSpan")),
190
+ [expr, t.stringLiteral(spanName)]
191
+ )
192
+ }
193
+
194
+ /**
195
+ * Finds or creates the Effect namespace import name.
196
+ */
197
+ function findOrGetEffectImport(ast: t.File): { effectName: string; genName: string | null } {
198
+ let effectName: string | null = null
199
+ let genName: string | null = null
200
+
201
+ for (const node of ast.program.body) {
202
+ if (!t.isImportDeclaration(node)) continue
203
+ if (node.source.value !== "effect") continue
204
+
205
+ for (const specifier of node.specifiers) {
206
+ // import * as Effect from "effect" or import { Effect } from "effect"
207
+ if (t.isImportNamespaceSpecifier(specifier)) {
208
+ effectName = specifier.local.name
209
+ } else if (t.isImportSpecifier(specifier)) {
210
+ const imported = t.isIdentifier(specifier.imported)
211
+ ? specifier.imported.name
212
+ : specifier.imported.value
213
+ if (imported === "Effect") {
214
+ effectName = specifier.local.name
215
+ } else if (imported === "gen") {
216
+ genName = specifier.local.name
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ return { effectName: effectName ?? "Effect", genName }
223
+ }
224
+
225
+ /**
226
+ * Ensures References is imported from effect.
227
+ */
228
+ function ensureReferencesImport(ast: t.File): string {
229
+ for (const node of ast.program.body) {
230
+ if (!t.isImportDeclaration(node)) continue
231
+ if (node.source.value !== "effect") continue
232
+
233
+ for (const specifier of node.specifiers) {
234
+ if (t.isImportSpecifier(specifier)) {
235
+ const imported = t.isIdentifier(specifier.imported)
236
+ ? specifier.imported.name
237
+ : specifier.imported.value
238
+ if (imported === "References") {
239
+ return specifier.local.name
240
+ }
241
+ }
242
+ }
243
+
244
+ // Add References to existing effect import
245
+ node.specifiers.push(
246
+ t.importSpecifier(t.identifier("References"), t.identifier("References"))
247
+ )
248
+ return "References"
249
+ }
250
+
251
+ // No effect import found - add one (unlikely scenario)
252
+ const importDecl = t.importDeclaration(
253
+ [t.importSpecifier(t.identifier("References"), t.identifier("References"))],
254
+ t.stringLiteral("effect")
255
+ )
256
+ ast.program.body.unshift(importDecl)
257
+ return "References"
258
+ }
259
+
260
+ /**
261
+ * Extracts the filename from a full path.
262
+ */
263
+ function getFileName(filePath: string): string {
264
+ const parts = filePath.split("/")
265
+ return parts[parts.length - 1] ?? filePath
266
+ }
267
+
268
+ /**
269
+ * Transforms source code to inject source location tracing and span instrumentation.
270
+ *
271
+ * @since 0.0.1
272
+ * @category transform
273
+ */
274
+ export function transform(
275
+ code: string,
276
+ id: string,
277
+ options: SourceTraceOptions = {}
278
+ ): TransformResult {
279
+ const enableSourceTrace = options.sourceTrace !== false
280
+ const extractFnName = options.extractFunctionName !== false
281
+ const spanOptions = options.spans
282
+ const enableSpans = spanOptions?.enabled === true
283
+
284
+ let ast: t.File
285
+ try {
286
+ ast = parse(code, {
287
+ sourceType: "module",
288
+ plugins: ["typescript", "jsx"],
289
+ sourceFilename: id
290
+ })
291
+ } catch {
292
+ return { code, transformed: false }
293
+ }
294
+
295
+ const { effectName, genName } = findOrGetEffectImport(ast)
296
+
297
+ // No Effect import found
298
+ if (!effectName && !genName) {
299
+ return { code, transformed: false }
300
+ }
301
+
302
+ let hasTransformed = false
303
+ const fileName = getFileName(id)
304
+
305
+ // Span instrumentation pass
306
+ if (enableSpans && effectName) {
307
+ const instrumentable = resolveInstrumentable(spanOptions!)
308
+ const wrappedNodes = new WeakSet<t.CallExpression>()
309
+
310
+ traverse(ast, {
311
+ CallExpression(path: NodePath<t.CallExpression>) {
312
+ if (wrappedNodes.has(path.node)) return
313
+
314
+ const callee = path.node.callee
315
+ if (!t.isMemberExpression(callee)) return
316
+ if (!t.isIdentifier(callee.object) || callee.object.name !== effectName) return
317
+ if (!t.isIdentifier(callee.property)) return
318
+
319
+ const methodName = callee.property.name
320
+ if (!instrumentable.has(methodName)) return
321
+
322
+ const loc = path.node.loc
323
+ if (!loc) return
324
+
325
+ const variableName = getAssignedVariableName(path)
326
+ const spanName = createSpanName(variableName, fileName, loc.start.line)
327
+
328
+ const wrapped = wrapWithSpan(path.node, spanName, effectName)
329
+ wrappedNodes.add(path.node)
330
+ path.replaceWith(wrapped)
331
+ hasTransformed = true
332
+ }
333
+ })
334
+ }
335
+
336
+ // Source trace pass
337
+ if (enableSourceTrace) {
338
+ const framesByLocation = new Map<string, StackFrameInfo>()
339
+ let frameCounter = 0
340
+
341
+ // First pass: collect all yield* locations and create frame info
342
+ traverse(ast, {
343
+ CallExpression(path: NodePath<t.CallExpression>) {
344
+ if (!isEffectGenCall(path.node, effectName, genName)) return
345
+
346
+ const generatorArg = path.node.arguments[0]
347
+ if (!t.isFunctionExpression(generatorArg) || !generatorArg.generator) return
348
+
349
+ path.traverse({
350
+ // Skip nested Effect.gen calls to avoid processing their yield* twice
351
+ CallExpression(nestedPath: NodePath<t.CallExpression>) {
352
+ if (isEffectGenCall(nestedPath.node, effectName, genName)) {
353
+ nestedPath.skip()
354
+ }
355
+ },
356
+ YieldExpression(yieldPath: NodePath<t.YieldExpression>) {
357
+ // Only process yield* (delegate)
358
+ if (!yieldPath.node.delegate || !yieldPath.node.argument) return
359
+
360
+ const loc = yieldPath.node.loc
361
+ if (!loc) return
362
+
363
+ const location = `${id}:${loc.start.line}:${loc.start.column}`
364
+
365
+ if (!framesByLocation.has(location)) {
366
+ const name = extractFnName
367
+ ? extractFunctionName(yieldPath.node.argument)
368
+ : "effect"
369
+ const varName = `_sf${frameCounter++}`
370
+ const info: StackFrameInfo = { name, location, varName }
371
+ framesByLocation.set(location, info)
372
+ }
373
+ }
374
+ })
375
+ }
376
+ })
377
+
378
+ if (framesByLocation.size > 0) {
379
+ const referencesName = ensureReferencesImport(ast)
380
+
381
+ // Second pass: wrap yield* expressions
382
+ traverse(ast, {
383
+ CallExpression(path: NodePath<t.CallExpression>) {
384
+ if (!isEffectGenCall(path.node, effectName, genName)) return
385
+
386
+ const generatorArg = path.node.arguments[0]
387
+ if (!t.isFunctionExpression(generatorArg) || !generatorArg.generator) return
388
+
389
+ path.traverse({
390
+ // Skip nested Effect.gen calls to avoid processing their yield* twice
391
+ CallExpression(nestedPath: NodePath<t.CallExpression>) {
392
+ if (isEffectGenCall(nestedPath.node, effectName, genName)) {
393
+ nestedPath.skip()
394
+ }
395
+ },
396
+ YieldExpression(yieldPath: NodePath<t.YieldExpression>) {
397
+ if (!yieldPath.node.delegate || !yieldPath.node.argument) return
398
+
399
+ const loc = yieldPath.node.loc
400
+ if (!loc) return
401
+
402
+ const location = `${id}:${loc.start.line}:${loc.start.column}`
403
+ const frame = framesByLocation.get(location)
404
+ if (!frame) return
405
+
406
+ const wrapped = wrapWithUpdateService(
407
+ yieldPath.node.argument,
408
+ frame.varName,
409
+ effectName!,
410
+ referencesName
411
+ )
412
+ yieldPath.node.argument = wrapped
413
+ hasTransformed = true
414
+ }
415
+ })
416
+ }
417
+ })
418
+
419
+ // Insert hoisted frame declarations after imports
420
+ const frameDeclarations = Array.from(framesByLocation.values()).map(createStackFrameDeclaration)
421
+ let insertIndex = 0
422
+ for (let i = 0; i < ast.program.body.length; i++) {
423
+ if (!t.isImportDeclaration(ast.program.body[i])) {
424
+ insertIndex = i
425
+ break
426
+ }
427
+ insertIndex = i + 1
428
+ }
429
+ ast.program.body.splice(insertIndex, 0, ...frameDeclarations)
430
+ }
431
+ }
432
+
433
+ if (!hasTransformed) {
434
+ return { code, transformed: false }
435
+ }
436
+
437
+ const result = generate(ast, {
438
+ sourceMaps: true,
439
+ sourceFileName: id
440
+ }, code)
441
+
442
+ return {
443
+ code: result.code,
444
+ map: result.map,
445
+ transformed: true
446
+ }
447
+ }
package/src/types.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @since 0.0.1
3
+ */
4
+
5
+ /**
6
+ * Filter pattern for file matching.
7
+ *
8
+ * @since 0.0.1
9
+ * @category models
10
+ */
11
+ export type FilterPattern = string | RegExp | ReadonlyArray<string | RegExp>
12
+
13
+ /**
14
+ * Effect combinators that can be auto-instrumented with spans.
15
+ *
16
+ * @since 0.0.1
17
+ * @category models
18
+ */
19
+ export type InstrumentableEffect =
20
+ | "gen"
21
+ | "fork"
22
+ | "forkDaemon"
23
+ | "forkScoped"
24
+ | "all"
25
+ | "forEach"
26
+ | "filter"
27
+ | "reduce"
28
+ | "iterate"
29
+ | "loop"
30
+
31
+ /**
32
+ * Options for auto-instrumentation with withSpan.
33
+ *
34
+ * @since 0.0.1
35
+ * @category models
36
+ */
37
+ export interface SpanInstrumentationOptions {
38
+ /**
39
+ * Enable auto-instrumentation with withSpan.
40
+ * @default false
41
+ */
42
+ readonly enabled?: boolean | undefined
43
+ /**
44
+ * Effect combinators to instrument. Defaults to all supported combinators.
45
+ */
46
+ readonly include?: ReadonlyArray<InstrumentableEffect> | undefined
47
+ /**
48
+ * Effect combinators to exclude from instrumentation.
49
+ */
50
+ readonly exclude?: ReadonlyArray<InstrumentableEffect> | undefined
51
+ }
52
+
53
+ /**
54
+ * Options for the source trace transformer plugin.
55
+ *
56
+ * @since 0.0.1
57
+ * @category models
58
+ */
59
+ export interface SourceTraceOptions {
60
+ /**
61
+ * Files to include in transformation. Defaults to TypeScript/JavaScript files.
62
+ */
63
+ readonly include?: FilterPattern | undefined
64
+ /**
65
+ * Files to exclude from transformation. Defaults to node_modules.
66
+ */
67
+ readonly exclude?: FilterPattern | undefined
68
+ /**
69
+ * Extract function name from yield* expression for the stack frame.
70
+ * @default true
71
+ */
72
+ readonly extractFunctionName?: boolean | undefined
73
+ /**
74
+ * Enable yield* source tracing with CurrentStackFrame.
75
+ * @default true
76
+ */
77
+ readonly sourceTrace?: boolean | undefined
78
+ /**
79
+ * Auto-instrumentation options for wrapping Effect combinators with withSpan.
80
+ */
81
+ readonly spans?: SpanInstrumentationOptions | undefined
82
+ /**
83
+ * Add @__PURE__ annotations to Effect calls for tree-shaking.
84
+ * @default false
85
+ */
86
+ readonly annotateEffects?: boolean | undefined
87
+ }
package/src/vite.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Vite plugin for Effect source location tracing.
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * // vite.config.ts
7
+ * import { defineConfig } from "vite"
8
+ * import effectSourceTrace from "@effect/unplugin/vite"
9
+ *
10
+ * export default defineConfig({
11
+ * plugins: [effectSourceTrace()]
12
+ * })
13
+ * ```
14
+ *
15
+ * @since 0.0.1
16
+ */
17
+ import unplugin from "./index.ts"
18
+
19
+ export default unplugin.vite
package/src/webpack.ts ADDED
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Webpack plugin for Effect source location tracing.
3
+ *
4
+ * @example
5
+ * ```ts
6
+ * // webpack.config.js
7
+ * import effectSourceTrace from "@effect/unplugin/webpack"
8
+ *
9
+ * export default {
10
+ * plugins: [effectSourceTrace()]
11
+ * }
12
+ * ```
13
+ *
14
+ * @since 0.0.1
15
+ */
16
+ import unplugin from "./index.ts"
17
+
18
+ export default unplugin.webpack