@eventra_dev/eventra-cli 2.2.0 → 2.3.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.
@@ -82,20 +82,6 @@ function moduleMatches(specifier, modules) {
82
82
  return modules.some((m) => specifier === m || specifier.startsWith(`${m}/`));
83
83
  }
84
84
  const MAX_ALIAS_HOPS = 6;
85
- /** Identifiers naming the types in a type annotation: `PostHog`, `PostHog | null`, `ph.PostHog`. */
86
- function typeReferenceRoots(type) {
87
- if (typescript_1.default.isParenthesizedTypeNode(type))
88
- return typeReferenceRoots(type.type);
89
- if (typescript_1.default.isUnionTypeNode(type) || typescript_1.default.isIntersectionTypeNode(type))
90
- return type.types.flatMap(typeReferenceRoots);
91
- if (typescript_1.default.isTypeReferenceNode(type)) {
92
- let name = type.typeName;
93
- while (typescript_1.default.isQualifiedName(name))
94
- name = name.left;
95
- return [name];
96
- }
97
- return [];
98
- }
99
85
  /**
100
86
  * The symbol an identifier refers to, keeping an import as the import binding itself. TS resolves an
101
87
  * import all the way to its target in some positions once something makes the module resolvable
@@ -109,6 +95,55 @@ function symbolAt(id, checker) {
109
95
  return symbol;
110
96
  return checker.getSymbolsInScope(id, typescript_1.default.SymbolFlags.Alias).find((alias) => alias.name === id.text) ?? symbol;
111
97
  }
98
+ /** `import("m")`: the specifier of the call decides, no installed package needed. */
99
+ function dynamicImportFrom(expression, modules) {
100
+ const specifier = typescript_1.default.isCallExpression(expression) && expression.expression.kind === typescript_1.default.SyntaxKind.ImportKeyword
101
+ ? expression.arguments[0]
102
+ : undefined;
103
+ return specifier !== undefined && typescript_1.default.isStringLiteralLike(specifier) && moduleMatches(specifier.text, modules);
104
+ }
105
+ const PROMISE_TYPES = new Set(["Promise", "PromiseLike"]);
106
+ /**
107
+ * True when a type annotation names something from `modules`: `PostHog`, `PostHog | null`, `ph.PostHog`,
108
+ * `typeof import("posthog-js")`, `Promise<typeof import("posthog-js")>`, `typeof posthog`.
109
+ */
110
+ function typeFromModules(type, checker, modules, hops) {
111
+ if (typescript_1.default.isParenthesizedTypeNode(type))
112
+ return typeFromModules(type.type, checker, modules, hops);
113
+ if (typescript_1.default.isUnionTypeNode(type) || typescript_1.default.isIntersectionTypeNode(type)) {
114
+ return type.types.some((member) => typeFromModules(member, checker, modules, hops));
115
+ }
116
+ if (typescript_1.default.isImportTypeNode(type)) {
117
+ return typescript_1.default.isLiteralTypeNode(type.argument) && typescript_1.default.isStringLiteral(type.argument.literal) && moduleMatches(type.argument.literal.text, modules);
118
+ }
119
+ const name = typescript_1.default.isTypeReferenceNode(type) ? type.typeName : typescript_1.default.isTypeQueryNode(type) ? type.exprName : undefined;
120
+ if (name === undefined)
121
+ return false;
122
+ let root = name;
123
+ while (typescript_1.default.isQualifiedName(root))
124
+ root = root.left;
125
+ return (symbolFromModules(symbolAt(root, checker), checker, modules, hops + 1) ||
126
+ // a promise of the module (`Promise<typeof import("posthog-js")>`), not any generic that mentions it
127
+ (typescript_1.default.isTypeReferenceNode(type) &&
128
+ typescript_1.default.isIdentifier(type.typeName) &&
129
+ PROMISE_TYPES.has(type.typeName.text) &&
130
+ (type.typeArguments ?? []).some((argument) => typeFromModules(argument, checker, modules, hops))));
131
+ }
132
+ /** `return expr` statements of a function body, not looking into functions nested in it. */
133
+ function returnedExpressions(body) {
134
+ if (!typescript_1.default.isBlock(body))
135
+ return [body];
136
+ const found = [];
137
+ const visit = (node) => {
138
+ if (typescript_1.default.isFunctionLike(node))
139
+ return;
140
+ if (typescript_1.default.isReturnStatement(node) && node.expression)
141
+ found.push(node.expression);
142
+ typescript_1.default.forEachChild(node, visit);
143
+ };
144
+ typescript_1.default.forEachChild(body, visit);
145
+ return found;
146
+ }
112
147
  /** The symbol `name` exported by the module a specifier points at, when TS can resolve that module. */
113
148
  function resolveExport(specifier, name, checker) {
114
149
  const moduleSymbol = checker.getSymbolAtLocation(specifier);
@@ -123,6 +158,8 @@ function expressionFromModules(expression, checker, modules, hops) {
123
158
  let expr = (0, utils_1.unwrapExpression)(expression);
124
159
  if (typescript_1.default.isAwaitExpression(expr))
125
160
  expr = (0, utils_1.unwrapExpression)(expr.expression);
161
+ if (dynamicImportFrom(expr, modules))
162
+ return true;
126
163
  if (typescript_1.default.isCallExpression(expr) || typescript_1.default.isNewExpression(expr))
127
164
  expr = (0, utils_1.unwrapExpression)(expr.expression);
128
165
  const fromName = (name) => typescript_1.default.isIdentifier(name) && symbolFromModules(symbolAt(name, checker), checker, modules, hops);
@@ -133,7 +170,10 @@ function expressionFromModules(expression, checker, modules, hops) {
133
170
  let root = expr;
134
171
  while (typescript_1.default.isPropertyAccessExpression(root))
135
172
  root = (0, utils_1.unwrapExpression)(root.expression);
136
- return fromName(expr.name) || fromName(root);
173
+ // `(await import("posthog-js")).default`
174
+ if (typescript_1.default.isAwaitExpression(root))
175
+ root = (0, utils_1.unwrapExpression)(root.expression);
176
+ return fromName(expr.name) || fromName(root) || dynamicImportFrom(root, modules);
137
177
  }
138
178
  function declarationFromModules(decl, checker, modules, hops) {
139
179
  if (typescript_1.default.isImportSpecifier(decl) || typescript_1.default.isImportClause(decl) || typescript_1.default.isNamespaceImport(decl)) {
@@ -162,15 +202,42 @@ function declarationFromModules(decl, checker, modules, hops) {
162
202
  if (typescript_1.default.isExportAssignment(decl)) {
163
203
  return expressionFromModules(decl.expression, checker, modules, hops + 1);
164
204
  }
205
+ // function useAnalytics() { return usePostHog() } / const load = () => import("posthog-js"): a function
206
+ // gives back what it returns (or what its return annotation names)
207
+ const fn = typescript_1.default.isFunctionDeclaration(decl) || typescript_1.default.isMethodDeclaration(decl)
208
+ ? decl
209
+ : (typescript_1.default.isVariableDeclaration(decl) || typescript_1.default.isPropertyDeclaration(decl)) &&
210
+ decl.initializer &&
211
+ (typescript_1.default.isArrowFunction(decl.initializer) || typescript_1.default.isFunctionExpression(decl.initializer))
212
+ ? decl.initializer
213
+ : undefined;
214
+ if (fn) {
215
+ if (fn.type && typeFromModules(fn.type, checker, modules, hops))
216
+ return true;
217
+ if (fn.body && returnedExpressions(fn.body).some((returned) => expressionFromModules(returned, checker, modules, hops + 1))) {
218
+ return true;
219
+ }
220
+ }
165
221
  // const { posthog } = usePostHog(): the binding's own declaration is the enclosing variable.
166
222
  const owner = typescript_1.default.isBindingElement(decl) ? decl.parent.parent : decl;
223
+ // promise.then(({ default: posthog }) => ...) / promise.then((mod) => ...): the parameter holds what the promise resolves to
224
+ if (typescript_1.default.isParameter(owner)) {
225
+ const callback = owner.parent;
226
+ const call = callback.parent;
227
+ if ((typescript_1.default.isArrowFunction(callback) || typescript_1.default.isFunctionExpression(callback)) &&
228
+ typescript_1.default.isCallExpression(call) &&
229
+ call.arguments[0] === callback &&
230
+ typescript_1.default.isPropertyAccessExpression(call.expression) &&
231
+ call.expression.name.text === "then") {
232
+ return expressionFromModules(call.expression.expression, checker, modules, hops + 1);
233
+ }
234
+ }
167
235
  // Variables, properties, parameters, functions (return type) and type aliases all keep their
168
236
  // annotation in `type`; variables, properties and parameters keep a default/initial value in
169
237
  // `initializer`. Anything else (classes, interfaces, ...) simply has neither.
170
238
  const { type, initializer } = owner;
171
239
  // let client: PostHog | null / private client: PostHog / (client: PostHog) => ... / type Client = PostHog
172
- if (type &&
173
- typeReferenceRoots(type).some((id) => symbolFromModules(symbolAt(id, checker), checker, modules, hops + 1))) {
240
+ if (type && typeFromModules(type, checker, modules, hops)) {
174
241
  return true;
175
242
  }
176
243
  // const ph = usePostHog() / new PostHog(k) / client = new PostHog(k) / const useTrack = usePostHog
@@ -28,6 +28,9 @@ const consoleDynamicEventReporter = {
28
28
  if (occ.inTemplate) {
29
29
  console.log(chalk_1.default.yellow(`~ template of ${file}: event="${occ.eventExpression ?? ""}"${values}`));
30
30
  }
31
+ else if (occ.inTemplateCode) {
32
+ console.log(chalk_1.default.yellow(`~ template of ${file}: ${occ.calleeText}(...)${values}`));
33
+ }
31
34
  else {
32
35
  console.log(chalk_1.default.yellow(`~ ${file}:${occ.line} ${occ.calleeText}(...)${values}`));
33
36
  }
@@ -7,6 +7,20 @@ exports.PluginRegistry = void 0;
7
7
  const path_1 = __importDefault(require("path"));
8
8
  // the synthetic callee each framework plugin uses for a template `event=...`
9
9
  const TEMPLATE_CALLEE = /^__eventra_[a-z]+_template_event__$/;
10
+ // A plugin starts the code it generates from a template with this comment line, so a call found
11
+ // below it is not on a line the user wrote.
12
+ const TEMPLATE_REGION_START = /^\s*\/\/ --- [a-z]+ template \(auto-generated\) ---$/;
13
+ // optional: a module that carries more after the generated code (an Angular component class) ends it
14
+ const TEMPLATE_REGION_END = /^\s*\/\/ --- end template ---$/;
15
+ // 1-based lines of the generated template region in a virtual module, or undefined when it has none
16
+ function templateRegionOf(content) {
17
+ const lines = content.split("\n");
18
+ const start = lines.findIndex((line) => TEMPLATE_REGION_START.test(line));
19
+ if (start === -1)
20
+ return undefined;
21
+ const end = lines.findIndex((line, index) => index > start && TEMPLATE_REGION_END.test(line));
22
+ return { from: start + 1, to: end === -1 ? Infinity : end + 1 };
23
+ }
10
24
  // `__eventra_x_template_event__(expr)` (callText may be cut with an ellipsis)
11
25
  function templateExpression(occurrence) {
12
26
  const open = occurrence.callText.indexOf("(");
@@ -22,6 +36,7 @@ class PluginRegistry {
22
36
  dynamicEventReporters = [];
23
37
  includePatterns = [];
24
38
  sourceToVirtuals = new Map();
39
+ templateRegions = new Map();
25
40
  registerFilePreprocessor(preprocessor) {
26
41
  if (this.preprocessors.some((p) => p.name === preprocessor.name)) {
27
42
  throw new Error(`File preprocessor already registered: ${preprocessor.name}`);
@@ -85,10 +100,23 @@ class PluginRegistry {
85
100
  }
86
101
  const files = await preprocessor.process(input);
87
102
  if (files.length > 0) {
103
+ // virtual files this source produced before and no longer does: their regions are not valid for anything else
104
+ for (const stale of this.sourceToVirtuals.get(sourceKey) ?? [])
105
+ this.templateRegions.delete(stale);
106
+ for (const file of files) {
107
+ const key = this.normalizePath(file.fileName);
108
+ const region = templateRegionOf(file.content);
109
+ if (region === undefined)
110
+ this.templateRegions.delete(key);
111
+ else
112
+ this.templateRegions.set(key, region);
113
+ }
88
114
  this.sourceToVirtuals.set(sourceKey, files.map((file) => this.normalizePath(file.fileName)));
89
115
  return files;
90
116
  }
91
117
  }
118
+ for (const stale of this.sourceToVirtuals.get(sourceKey) ?? [])
119
+ this.templateRegions.delete(stale);
92
120
  this.sourceToVirtuals.delete(sourceKey);
93
121
  return [{ fileName: input.fileName, content: input.content }];
94
122
  }
@@ -147,6 +175,11 @@ class PluginRegistry {
147
175
  eventExpression: templateExpression(occurrence),
148
176
  };
149
177
  }
178
+ // ordinary code the plugin copied out of the template: its line is in the generated text
179
+ const region = this.templateRegions.get(this.normalizePath(occurrence.fileName));
180
+ if (region !== undefined && occurrence.line >= region.from && occurrence.line <= region.to) {
181
+ return { ...occurrence, fileName: source, inTemplateCode: true };
182
+ }
150
183
  // an Angular template module embeds the component class: lines there
151
184
  // are generated, the real call is also reported from the class file
152
185
  return source.toLowerCase().endsWith(".html")
@@ -0,0 +1,89 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.defaultRespawnDeps = exports.SCANNING_COMMANDS = exports.RESPAWN_ENV = void 0;
7
+ exports.wantedHeapMb = wantedHeapMb;
8
+ exports.usableMemory = usableMemory;
9
+ exports.respawnWithMoreHeap = respawnWithMoreHeap;
10
+ const child_process_1 = require("child_process");
11
+ const os_1 = __importDefault(require("os"));
12
+ const v8_1 = __importDefault(require("v8"));
13
+ /** Set on the child, so a process started with more memory never starts another one. */
14
+ exports.RESPAWN_ENV = "EVENTRA_HEAP_RESPAWNED";
15
+ /** The commands that read the whole project; the rest (`init`, `--version`, `--help`) stay in one process. */
16
+ exports.SCANNING_COMMANDS = new Set(["sync", "check", "watch", "send"]);
17
+ const MIB = 1024 * 1024;
18
+ // a project of tens of thousands of files needs a few GB while its program is built; more than
19
+ // this is never useful, and half the machine's memory is what can be taken without hurting it
20
+ const MAX_HEAP_MB = 8192;
21
+ const SHARE_OF_MEMORY = 0.5;
22
+ /** Signals a parent passes on, so stopping it stops the child too instead of leaving it running. */
23
+ const FORWARDED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
24
+ /**
25
+ * The old-space limit (MB) worth starting with, or `undefined` to carry on as is: Node's default
26
+ * limit is a fraction of the memory and can be well below what a large monorepo needs.
27
+ */
28
+ function wantedHeapMb(availableMemoryBytes, currentLimitBytes) {
29
+ const wanted = Math.min(MAX_HEAP_MB, Math.floor((availableMemoryBytes * SHARE_OF_MEMORY) / MIB));
30
+ // not worth a second process for a small difference
31
+ return wanted > (currentLimitBytes / MIB) * 1.25 ? wanted : undefined;
32
+ }
33
+ /**
34
+ * Memory this process can use: the smaller of the machine's and the container's limit (cgroups) when it has
35
+ * one. `constrained` is 0 when nothing limits the process, and undefined on a Node that does not report it.
36
+ */
37
+ function usableMemory(total, constrained) {
38
+ return constrained !== undefined && constrained > 0 ? Math.min(total, constrained) : total;
39
+ }
40
+ function runChild(command, args, env) {
41
+ return new Promise((resolve) => {
42
+ const child = (0, child_process_1.spawn)(command, [...args], { stdio: "inherit", env });
43
+ const forwarders = FORWARDED_SIGNALS.map((signal) => {
44
+ const forward = () => child.kill(signal);
45
+ process.on(signal, forward);
46
+ return [signal, forward];
47
+ });
48
+ const done = (code) => {
49
+ for (const [signal, forward] of forwarders)
50
+ process.off(signal, forward);
51
+ resolve(code);
52
+ };
53
+ // a child that could not start at all (`error`) counts as a failed run, like one killed by a signal
54
+ child.on("error", () => done(1));
55
+ child.on("exit", (code) => done(code ?? 1));
56
+ });
57
+ }
58
+ const defaultRespawnDeps = () => ({
59
+ env: process.env,
60
+ execArgv: process.execArgv,
61
+ argv: process.argv,
62
+ execPath: process.execPath,
63
+ availableMemoryBytes: usableMemory(os_1.default.totalmem(), os_1.default.constrainedMemory?.()),
64
+ heapLimitBytes: v8_1.default.getHeapStatistics().heap_size_limit,
65
+ run: runChild,
66
+ exit: (code) => process.exit(code),
67
+ });
68
+ exports.defaultRespawnDeps = defaultRespawnDeps;
69
+ /**
70
+ * Runs a scanning command again in a child with a larger heap when this process would start with too
71
+ * little, and exits with the child's status. Resolves `true` when it did, so the caller stops there. An
72
+ * explicit `--max-old-space-size` (in NODE_OPTIONS or the command line) is the user's choice and is left alone.
73
+ */
74
+ async function respawnWithMoreHeap(deps = (0, exports.defaultRespawnDeps)()) {
75
+ if (deps.env[exports.RESPAWN_ENV])
76
+ return false;
77
+ // a second process costs startup time, so only a command that scans a project is worth it
78
+ if (!exports.SCANNING_COMMANDS.has(deps.argv[2] ?? ""))
79
+ return false;
80
+ const explicit = [deps.env.NODE_OPTIONS ?? "", ...deps.execArgv].some((part) => part.includes("max-old-space-size"));
81
+ if (explicit)
82
+ return false;
83
+ const heapMb = wantedHeapMb(deps.availableMemoryBytes, deps.heapLimitBytes);
84
+ if (heapMb === undefined)
85
+ return false;
86
+ const code = await deps.run(deps.execPath, [`--max-old-space-size=${heapMb}`, ...deps.execArgv, ...deps.argv.slice(1)], { ...deps.env, [exports.RESPAWN_ENV]: "1" });
87
+ deps.exit(code);
88
+ return true;
89
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventra_dev/eventra-cli",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "Static analytics event extraction CLI powered by the TypeScript compiler API",
5
5
  "license": "MIT",
6
6
  "author": {