@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.
- package/README.md +384 -0
- package/dist/annotateEffects.d.ts +17 -0
- package/dist/annotateEffects.d.ts.map +1 -0
- package/dist/annotateEffects.js +125 -0
- package/dist/annotateEffects.js.map +1 -0
- package/dist/esbuild.d.ts +3 -0
- package/dist/esbuild.d.ts.map +1 -0
- package/dist/esbuild.js +18 -0
- package/dist/esbuild.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +86 -0
- package/dist/index.js.map +1 -0
- package/dist/rollup.d.ts +3 -0
- package/dist/rollup.d.ts.map +1 -0
- package/dist/rollup.js +18 -0
- package/dist/rollup.js.map +1 -0
- package/dist/sourceTrace.d.ts +18 -0
- package/dist/sourceTrace.d.ts.map +1 -0
- package/dist/sourceTrace.js +451 -0
- package/dist/sourceTrace.js.map +1 -0
- package/dist/types.d.ts +145 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/vite.d.ts +3 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +19 -0
- package/dist/vite.js.map +1 -0
- package/dist/webpack.d.ts +3 -0
- package/dist/webpack.d.ts.map +1 -0
- package/dist/webpack.js +18 -0
- package/dist/webpack.js.map +1 -0
- package/package.json +102 -0
- package/src/esbuild.ts +18 -0
- package/src/index.ts +97 -0
- package/src/rollup.ts +18 -0
- package/src/sourceTrace.ts +667 -0
- package/src/types.ts +162 -0
- package/src/vite.ts +19 -0
- package/src/webpack.ts +18 -0
|
@@ -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
|
+
}
|