evergreen-trace-runtime 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.
package/cli.js ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { spawn } = require("node:child_process");
5
+ const path = require("node:path");
6
+
7
+ function printUsage() {
8
+ // eslint-disable-next-line no-console
9
+ console.log(
10
+ "Usage: evergreen-trace run <entry> [-- <args>...]\n" +
11
+ "Example: evergreen-trace run src/server.js -- --port 4321",
12
+ );
13
+ }
14
+
15
+ function run() {
16
+ const argv = process.argv.slice(2); // drop node + script
17
+
18
+ const [cmd, entry, ...rest] = argv;
19
+
20
+ if (cmd !== "run" || !entry) {
21
+ printUsage();
22
+ process.exit(1);
23
+ }
24
+
25
+ const entryPath = path.resolve(process.cwd(), entry);
26
+
27
+ const child = spawn(
28
+ process.execPath,
29
+ ["--require", "evergreen-trace-runtime/register", entryPath, ...rest],
30
+ { stdio: "inherit" },
31
+ );
32
+
33
+ child.on("exit", (code, signal) => {
34
+ if (signal) {
35
+ process.kill(process.pid, signal);
36
+ } else {
37
+ process.exit(code ?? 0);
38
+ }
39
+ });
40
+ }
41
+
42
+ run();
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "evergreen-trace-runtime",
3
+ "version": "0.1.0",
4
+ "description": "Evergreen Trace runtime loader and CLI",
5
+ "main": "register.js",
6
+ "bin": {
7
+ "evergreen-trace": "cli.js"
8
+ },
9
+ "exports": {
10
+ "./register": "./register.js"
11
+ },
12
+ "dependencies": {
13
+ "@babel/core": "^7.23.0",
14
+ "micromatch": "^4.0.0",
15
+ "evergreen-babel-plugin": "file:../evergreen-babel-plugin",
16
+ "evergreen-sdk": "file:../evergreen-sdk"
17
+ }
18
+ }
19
+
package/register.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // Runtime entrypoint for Evergreen Trace loader.
5
+ // In this monorepo, we reuse the root loader implementation.
6
+ // When publishing, this file can be replaced with a compiled copy.
7
+
8
+ module.exports = require("./src/register.js");
9
+
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const crypto = require("crypto");
6
+
7
+ function hashContent(content) {
8
+ return crypto.createHash("sha1").update(content, "utf8").digest("hex");
9
+ }
10
+
11
+ function hashConfig(config) {
12
+ const minimal = {
13
+ ignore: Array.isArray(config.ignore) ? config.ignore : [],
14
+ include: Array.isArray(config.include) ? config.include : [],
15
+ };
16
+ return crypto
17
+ .createHash("sha1")
18
+ .update(JSON.stringify(minimal), "utf8")
19
+ .digest("hex");
20
+ }
21
+
22
+ function hashKey(logicalKey) {
23
+ // Stable serialization
24
+ const json = JSON.stringify({
25
+ filename: logicalKey.filename,
26
+ contentHash: logicalKey.contentHash,
27
+ configHash: logicalKey.configHash,
28
+ toolVersion: logicalKey.toolVersion,
29
+ });
30
+ return crypto.createHash("sha1").update(json, "utf8").digest("hex");
31
+ }
32
+
33
+ function ensureDir(dirPath, onWarn) {
34
+ try {
35
+ if (!fs.existsSync(dirPath)) {
36
+ fs.mkdirSync(dirPath, { recursive: true });
37
+ }
38
+ } catch (err) {
39
+ if (onWarn) onWarn("[Evergreen Trace] Failed to ensure directory:", err);
40
+ }
41
+ }
42
+
43
+ function readJsonFile(filePath, onWarn) {
44
+ try {
45
+ if (!fs.existsSync(filePath)) return null;
46
+ const raw = fs.readFileSync(filePath, "utf8");
47
+ return JSON.parse(raw);
48
+ } catch (err) {
49
+ if (onWarn) onWarn("[Evergreen Trace] Failed to read cache entry:", err);
50
+ return null;
51
+ }
52
+ }
53
+
54
+ function writeJsonFile(filePath, payload, onWarn) {
55
+ try {
56
+ fs.writeFileSync(filePath, JSON.stringify(payload), "utf8");
57
+ } catch (err) {
58
+ if (onWarn) onWarn("[Evergreen Trace] Failed to write cache entry:", err);
59
+ }
60
+ }
61
+
62
+ /**
63
+ * @param {object} args
64
+ * @param {string} args.projectRoot
65
+ * @param {(msg: string, ...args: any[]) => void} [args.onWarn]
66
+ * @returns {{ getOrTransform: (content: string, filename: string, config: any, instrumentSource: Function) => { code: string, map: any, cacheKey: string, hit: boolean, meta: any } }}
67
+ */
68
+ function createInstrumentCache({ projectRoot, onWarn }) {
69
+ const cacheEntriesDir = path.join(projectRoot, ".evergreen-cache", "entries");
70
+ ensureDir(cacheEntriesDir, onWarn);
71
+
72
+ function getOrTransform(content, filename, config, instrumentSource) {
73
+ const contentHash = hashContent(content);
74
+ const configHash = hashConfig(config);
75
+ const toolVersion = "v1";
76
+
77
+ const meta = { filename, contentHash, configHash, toolVersion };
78
+ const cacheKey = hashKey(meta);
79
+ const entryPath = path.join(cacheEntriesDir, `${cacheKey}.json`);
80
+
81
+ const cached = readJsonFile(entryPath, onWarn);
82
+ if (cached && typeof cached.code === "string") {
83
+ return {
84
+ code: cached.code,
85
+ map: cached.map || null,
86
+ cacheKey,
87
+ hit: true,
88
+ meta: cached.meta || meta,
89
+ };
90
+ }
91
+
92
+ const { code, map } = instrumentSource(content, filename, config);
93
+
94
+ writeJsonFile(
95
+ entryPath,
96
+ {
97
+ code,
98
+ map: map || null,
99
+ meta,
100
+ },
101
+ onWarn,
102
+ );
103
+
104
+ return { code, map: map || null, cacheKey, hit: false, meta };
105
+ }
106
+
107
+ return { getOrTransform };
108
+ }
109
+
110
+ module.exports = {
111
+ createInstrumentCache,
112
+ };
113
+
@@ -0,0 +1,324 @@
1
+ // register.js
2
+ "use strict";
3
+
4
+ const fs = require("fs");
5
+ const path = require("path");
6
+ const Module = require("module");
7
+ const micromatch = require("micromatch");
8
+ const { instrumentSource } = require("./transform");
9
+ const { createInstrumentCache } = require("./instrument-cache");
10
+
11
+ // -----------------------------------------------------------------------------
12
+ // 1. Project root detection
13
+ // -----------------------------------------------------------------------------
14
+
15
+ // For MVP we treat the CWD as the project root. Later you can walk up to find package.json.
16
+ const PROJECT_ROOT = process.cwd();
17
+ // Cache is managed by src/instrument-cache.js
18
+
19
+ // -----------------------------------------------------------------------------
20
+ // 2. Config loading
21
+ // -----------------------------------------------------------------------------
22
+
23
+ /**
24
+ * Shape of the in-memory Evergreen config.
25
+ * ignore/include are arrays of glob strings (not yet compiled).
26
+ */
27
+ function loadConfig() {
28
+ const defaultConfig = {
29
+ ignore: ["node_modules/**", "dist/**", "coverage/**"],
30
+ include: [], // empty means "everything else"
31
+ };
32
+
33
+ let fileConfig = {};
34
+ const configPath = path.join(PROJECT_ROOT, ".evergreen-trace.json");
35
+
36
+ try {
37
+ if (fs.existsSync(configPath)) {
38
+ const raw = fs.readFileSync(configPath, "utf8");
39
+ const parsed = JSON.parse(raw);
40
+ if (parsed && typeof parsed === "object") {
41
+ fileConfig = {
42
+ ignore: Array.isArray(parsed.ignore) ? parsed.ignore : [],
43
+ include: Array.isArray(parsed.include) ? parsed.include : [],
44
+ };
45
+ }
46
+ }
47
+ } catch (err) {
48
+ // Fail-soft: just log and fall back to defaults.
49
+ safeLogWarn("[Evergreen Trace] Failed to load .evergreen-trace.json:", err);
50
+ }
51
+
52
+ // Env overrides (comma-separated lists)
53
+ const envIgnore =
54
+ process.env.EVERGREEN_TRACE_IGNORE &&
55
+ process.env.EVERGREEN_TRACE_IGNORE.split(",")
56
+ .map((s) => s.trim())
57
+ .filter(Boolean);
58
+
59
+ const envInclude =
60
+ process.env.EVERGREEN_TRACE_INCLUDE &&
61
+ process.env.EVERGREEN_TRACE_INCLUDE.split(",")
62
+ .map((s) => s.trim())
63
+ .filter(Boolean);
64
+
65
+ return {
66
+ ignore:
67
+ envIgnore && envIgnore.length
68
+ ? envIgnore
69
+ : fileConfig.ignore || defaultConfig.ignore,
70
+ include:
71
+ envInclude && envInclude.length
72
+ ? envInclude
73
+ : fileConfig.include || defaultConfig.include,
74
+ };
75
+ }
76
+
77
+ const CONFIG = loadConfig();
78
+ const DEBUG =
79
+ process.env.EVERGREEN_TRACE_DEBUG === "1" ||
80
+ process.env.EVERGREEN_TRACE_DEBUG === "true";
81
+
82
+ const instrumentCache = createInstrumentCache({
83
+ projectRoot: PROJECT_ROOT,
84
+ onWarn: safeLogWarn,
85
+ });
86
+
87
+ // Pre-compile glob patterns for selective instrumentation (Phase 4).
88
+ const ignorePatterns = CONFIG.ignore || [];
89
+ const includePatterns = CONFIG.include || [];
90
+ const hasInclude = includePatterns.length > 0;
91
+
92
+ function matchesAny(patterns, relPath) {
93
+ return patterns.length > 0 && micromatch.isMatch(relPath, patterns);
94
+ }
95
+
96
+ // -----------------------------------------------------------------------------
97
+ // 3. Logging helpers (fail-soft)
98
+ // -----------------------------------------------------------------------------
99
+
100
+ function safeLog(...args) {
101
+ try {
102
+ // eslint-disable-next-line no-console
103
+ console.log(...args);
104
+ } catch (_) {
105
+ // ignore
106
+ }
107
+ }
108
+
109
+ function safeLogWarn(...args) {
110
+ try {
111
+ // eslint-disable-next-line no-console
112
+ console.warn(...args);
113
+ } catch (_) {
114
+ // ignore
115
+ }
116
+ }
117
+
118
+ function debugLog(...args) {
119
+ if (!DEBUG) return;
120
+ safeLog("[Evergreen Trace DEBUG]", ...args);
121
+ }
122
+
123
+ // -----------------------------------------------------------------------------
124
+ // 4. Instrumentation decision stub (Phase 1)
125
+ // -----------------------------------------------------------------------------
126
+
127
+ /**
128
+ * Decide whether this absolute filename should be instrumented.
129
+ * Phase 1: only apply basic rules; glob-based include/ignore can be added later.
130
+ */
131
+ function shouldInstrument(filename) {
132
+ // Skip internal Node modules and non-absolute paths
133
+ if (!path.isAbsolute(filename)) {
134
+ debugLog("Skipping (non-absolute or internal):", filename);
135
+ return false;
136
+ }
137
+
138
+ const normalized = filename.split(path.sep).join(path.posix.sep);
139
+
140
+ // Skip anything under node_modules
141
+ if (normalized.includes("/node_modules/")) {
142
+ debugLog("Skipping (node_modules):", filename);
143
+ return false;
144
+ }
145
+
146
+ // Skip this repo's own extension/runtime code (e.g. src/register.js)
147
+ const srcRoot = path
148
+ .join(PROJECT_ROOT, "packages", "evergreen-trace", "src")
149
+ .split(path.sep)
150
+ .join(path.posix.sep);
151
+
152
+ if (normalized.startsWith(srcRoot)) {
153
+ debugLog("Skipping (extension/runtime src):", filename);
154
+ return false;
155
+ }
156
+
157
+ // Skip SDK build output to avoid require cycles
158
+ if (normalized.includes("/packages/evergreen-sdk/dist/")) {
159
+ debugLog("Skipping (SDK build output):", filename);
160
+ return false;
161
+ }
162
+
163
+ // Skip all tests/fixtures. Node's test runner may execute these under ESM
164
+ // semantics, which can conflict with CJS fixtures (module.exports) after
165
+ // transformation.
166
+ if (normalized.includes("/__tests__/")) {
167
+ debugLog("Skipping (tests/fixtures):", filename);
168
+ return false;
169
+ }
170
+
171
+ // Only instrument .js and .cjs
172
+ if (!filename.endsWith(".js") && !filename.endsWith(".cjs")) {
173
+ debugLog("Skipping (extension):", filename);
174
+ return false;
175
+ }
176
+
177
+ // Phase 4: config-based ignore/include using project-root-relative POSIX paths
178
+ const rel = path.relative(PROJECT_ROOT, filename);
179
+ const relPosix = rel.split(path.sep).join("/");
180
+
181
+ // Ignore takes precedence
182
+ if (matchesAny(ignorePatterns, relPosix)) {
183
+ debugLog("Skipping (config ignore):", filename);
184
+ return false;
185
+ }
186
+
187
+ // If include list exists, only instrument included paths
188
+ if (hasInclude && !matchesAny(includePatterns, relPosix)) {
189
+ debugLog("Skipping (not in include set):", filename);
190
+ return false;
191
+ }
192
+
193
+ return true;
194
+ }
195
+
196
+ // -----------------------------------------------------------------------------
197
+ // 5. Transform stub (Phase 1)
198
+ // -----------------------------------------------------------------------------
199
+
200
+ // -----------------------------------------------------------------------------
201
+ // 5. Cache-backed instrumentation (Phase 2)
202
+ // -----------------------------------------------------------------------------
203
+
204
+ function getTransformedFromCacheOrBabel(content, filename, config) {
205
+ const result = instrumentCache.getOrTransform(
206
+ content,
207
+ filename,
208
+ config,
209
+ instrumentSource,
210
+ );
211
+ if (result.hit) debugLog("Cache hit for", filename);
212
+ else debugLog("Cache miss for", filename, "- running Babel transform");
213
+ return { code: result.code, map: result.map || null };
214
+ }
215
+
216
+ function stripExistingSourceMappingURL(code) {
217
+ const lines = code.split("\n");
218
+ while (
219
+ lines.length > 0 &&
220
+ /sourceMappingURL=/.test(lines[lines.length - 1].trim())
221
+ ) {
222
+ lines.pop();
223
+ }
224
+ return lines.join("\n");
225
+ }
226
+
227
+ /**
228
+ * Phase 3: attach inline source map if available.
229
+ *
230
+ * @param {string} code
231
+ * @param {string} filename
232
+ * @param {any} maybeMap
233
+ */
234
+ function attachSourceMapIfPresent(code, filename, maybeMap) {
235
+ if (!maybeMap) {
236
+ return code;
237
+ }
238
+
239
+ try {
240
+ if (!maybeMap.file) {
241
+ maybeMap.file = filename;
242
+ }
243
+
244
+ const json = JSON.stringify(maybeMap);
245
+ const base64 = Buffer.from(json, "utf8").toString("base64");
246
+ const comment =
247
+ "//# sourceMappingURL=data:application/json;base64," + base64;
248
+
249
+ const stripped = stripExistingSourceMappingURL(code);
250
+
251
+ return stripped + "\n" + comment + "\n";
252
+ } catch (err) {
253
+ safeLogWarn(
254
+ "[Evergreen Trace] Failed to attach source map for",
255
+ filename,
256
+ err,
257
+ );
258
+ return code;
259
+ }
260
+ }
261
+
262
+ // -----------------------------------------------------------------------------
263
+ // 6. Capture and wrap Module.prototype._compile
264
+ // -----------------------------------------------------------------------------
265
+
266
+ const originalCompile = Module.prototype._compile;
267
+
268
+ function evergreenCompileHook(content, filename) {
269
+ // Early exit: if anything goes wrong, fall back to original behavior.
270
+ try {
271
+ if (!shouldInstrument(filename)) {
272
+ return originalCompile.call(this, content, filename);
273
+ }
274
+
275
+ debugLog("Instrumenting file:", filename);
276
+
277
+ // Phase 2: transform with Babel + Evergreen plugin, with persistent cache.
278
+ const { code, map } = getTransformedFromCacheOrBabel(
279
+ content,
280
+ filename,
281
+ CONFIG,
282
+ );
283
+
284
+ // Phase 2: ignore map until Phase 3 wires in source maps.
285
+ const finalCode = attachSourceMapIfPresent(code, filename, map);
286
+
287
+ return originalCompile.call(this, finalCode, filename);
288
+ } catch (err) {
289
+ safeLogWarn(
290
+ "[Evergreen Trace] Failed during compile hook for",
291
+ filename,
292
+ err,
293
+ );
294
+ // Fail-safe: execute original code so the app still runs.
295
+ return originalCompile.call(this, content, filename);
296
+ }
297
+ }
298
+
299
+ // Install the hook
300
+ Module.prototype._compile = function evergreenCompileWrapper(
301
+ content,
302
+ filename,
303
+ ) {
304
+ return evergreenCompileHook.call(this, content, filename);
305
+ };
306
+
307
+ // -----------------------------------------------------------------------------
308
+ // 7. Startup banner
309
+ // -----------------------------------------------------------------------------
310
+
311
+ safeLog("🌲 Evergreen Trace: Loader active (Phase 1).");
312
+
313
+ if (DEBUG) {
314
+ safeLog("[Evergreen Trace] DEBUG mode enabled.");
315
+ safeLog("[Evergreen Trace] Project root:", PROJECT_ROOT);
316
+ safeLog("[Evergreen Trace] Config:", CONFIG);
317
+ }
318
+
319
+ // Test hooks (used only in automated tests to verify config behavior)
320
+ if (process.env.NODE_ENV === "test") {
321
+ module.exports.__test__ = {
322
+ shouldInstrument,
323
+ };
324
+ }
@@ -0,0 +1,84 @@
1
+ "use strict";
2
+
3
+ const { transformSync } = require("@babel/core");
4
+ const path = require("path");
5
+
6
+ // Try to resolve the Evergreen Babel plugin. In this monorepo it should be
7
+ // available as a local package; in published form it will be a normal
8
+ // dependency.
9
+ let evergreenBabelPlugin;
10
+ try {
11
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
12
+ evergreenBabelPlugin = require("evergreen-babel-plugin");
13
+ // Support both default and named export styles
14
+ if (evergreenBabelPlugin && evergreenBabelPlugin.default) {
15
+ evergreenBabelPlugin = evergreenBabelPlugin.default;
16
+ }
17
+ } catch (err) {
18
+ throw new Error(
19
+ "[Evergreen Trace] Failed to load evergreen-babel-plugin. " +
20
+ "Ensure it is installed and built before using the loader.\n" +
21
+ String(err),
22
+ );
23
+ }
24
+
25
+ /**
26
+ * Perform on-the-fly instrumentation of a single source file.
27
+ * Returns transformed code and a source map.
28
+ *
29
+ * @param {string} content - Original source code.
30
+ * @param {string} filename - Absolute path to the file on disk.
31
+ * @param {object} config - Evergreen config (include/ignore etc).
32
+ * @returns {{ code: string; map: any }}
33
+ */
34
+ function instrumentSource(content, filename, config) {
35
+ const result = transformSync(content, {
36
+ filename,
37
+ // Pass config through in case the plugin wants to use it.
38
+ plugins: [[evergreenBabelPlugin, { evergreenConfig: config }]],
39
+ sourceMaps: true,
40
+ ast: false,
41
+ // Allow parsing modern syntax; tweak as needed.
42
+ parserOpts: {
43
+ sourceType: "unambiguous",
44
+ },
45
+ generatorOpts: {
46
+ // Keep output readable; not strictly required.
47
+ compact: false,
48
+ },
49
+ });
50
+
51
+ if (!result || typeof result.code !== "string") {
52
+ throw new Error(
53
+ `[Evergreen Trace] Babel transform returned no code for ${filename}`,
54
+ );
55
+ }
56
+
57
+ let map = result.map || null;
58
+
59
+ if (map) {
60
+ // Ensure the map clearly references the transformed file and original source.
61
+ map.file = filename;
62
+ if (!Array.isArray(map.sources) || map.sources.length === 0) {
63
+ map.sources = [filename];
64
+ } else {
65
+ map.sources[0] = filename;
66
+ }
67
+
68
+ // Normalize sources to absolute paths so debuggers and Jump to Code work
69
+ // regardless of how Babel chose to emit them.
70
+ map.sources = map.sources.map((s) =>
71
+ path.isAbsolute(s) ? s : path.resolve(path.dirname(filename), s),
72
+ );
73
+ }
74
+
75
+ return {
76
+ code: result.code,
77
+ map,
78
+ };
79
+ }
80
+
81
+ module.exports = {
82
+ instrumentSource,
83
+ };
84
+