@eventra_dev/eventra-cli 2.2.0 → 2.3.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/README.md CHANGED
@@ -95,6 +95,27 @@ function trackDestructured({ event: name }: { event: string }) {
95
95
  trackDestructured({ event: "checkout" });
96
96
  ```
97
97
 
98
+ A wrapper can call another wrapper, at any depth and across files. `trackEvent("x")` is found even when it only reaches the SDK through `capture`:
99
+
100
+ ```ts
101
+ // capture.ts
102
+ export function capture(name: string, props?: object) { posthog.capture(name, props); }
103
+
104
+ // track.ts
105
+ export function trackEvent<E extends string>(event: E) { capture(event); }
106
+
107
+ // page.ts
108
+ trackEvent("checkout_started");
109
+ ```
110
+
111
+ Functions handed back by a hook count too (`const analytics = useAnalytics(); analytics.capture("x")`, where the hook ends with `return { capture }`), and so does a library loaded on demand (`import("posthog-js").then(({ default: ph }) => ph.capture(name))`, `await import(...)`).
112
+
113
+ ### Monorepos
114
+
115
+ Each import is resolved with the compiler options of the nearest `tsconfig.json` above the file that writes it (a config that sets no `paths` keeps the root's aliases; `node_modules` is never read for options), so an app with its own `@/*` or `~/*` aliases works without a tsconfig at the repository root. When a root `tsconfig.json` only lists `references` (the usual Vite and Vue layout), the aliases of the referenced config are used. Packages of the same repository (`workspaces` or `pnpm-workspace.yaml`) are found by their folder (`@acme/lib`, `@acme/lib/hooks/use-x`, also when the package keeps its sources in `src/`), so the scan does not need `node_modules` to link them. A real install still takes precedence.
116
+
117
+ `sync`, `check`, `watch` and `send` on a machine with room to spare (a container's memory limit counts) start themselves again with a larger memory limit (half of the machine's memory, at most 8 GB), unless you set `--max-old-space-size` yourself; `init` and `--version` never do. A new `eventra.json` written by `init` leaves out tests and coverage output (see `sync.exclude`); a config that already exists keeps scanning what it scanned.
118
+
98
119
  ### Variables, templates, conditionals
99
120
 
100
121
  ```ts
@@ -156,7 +177,7 @@ Already have analytics in place? Declare it in `eventra.json` and the CLI treats
156
177
 
157
178
  Rules are plain data and never run code, so they are safe to commit. A rule with a typo, an invalid `when` or an unknown preset is skipped with a warning. A function with its own name (`trackEvent({ event: "x" })`) is not covered by a preset: add a rule such as `{ "callee": "trackEvent", "nameProperty": "event" }`.
158
179
 
159
- The [name rules](#event-name-rules-aligned-with-sdk) below apply to these events too. Spaces and colons are allowed, so names like `App: User Sign Up` or `Order Completed` are kept as written. Names with other characters, such as PostHog's `$`-prefixed system events (`$pageview`) or a name ending in `!`, are excluded from the scan result.
180
+ The [name rules](#event-name-rules-aligned-with-sdk) below apply to these events too. Spaces and colons are allowed, so names like `App: User Sign Up` or `Order Completed` are kept as written. PostHog's `$`-prefixed names (`$pageview`, `$pageleave`) are kept as well. Names with other characters, such as one ending in `!`, are excluded from the scan result.
160
181
 
161
182
  ---
162
183
 
@@ -165,7 +186,7 @@ The [name rules](#event-name-rules-aligned-with-sdk) below apply to these events
165
186
  | Rule | Limit |
166
187
  |------|--------|
167
188
  | Max length | **64 characters** (same as SDK) |
168
- | Allowed characters | `a-zA-Z0-9:_./-` and the space (so `App: User Sign Up` works); tabs, newlines and any other character are rejected |
189
+ | Allowed characters | `a-zA-Z0-9$:_./-` and the space (so `App: User Sign Up` and `$pageview` work); tabs, newlines and any other character are rejected |
169
190
  | Position | First argument of `.track(name, options?)` |
170
191
 
171
192
  The second argument (`userId`, `properties`) is **not** used as the event name.
@@ -194,7 +215,7 @@ tracker.track({ event: "click" });
194
215
 
195
216
  ### `eventra init`
196
217
 
197
- Creates `eventra.json`. It asks for an API key and an endpoint, then looks through `package.json` (and the `package.json` of every workspace member in a monorepo: `workspaces` or `pnpm-workspace.yaml`) for analytics libraries:
218
+ Creates `eventra.json`. It asks for an API key and an endpoint, then looks through `package.json` (and the `package.json` of every workspace member in a monorepo: `workspaces` or `pnpm-workspace.yaml`) for analytics libraries and for the framework (`vue`, `svelte`, `astro`, `@angular/core`), and writes the matching plugin into `plugins` when it is installed. If it is not, `init` prints the install command and the line to add instead: `sync` stops on a plugin it cannot load, so `init` never writes one that would make the next `sync` fail:
198
219
 
199
220
  ```
200
221
  Found analytics in package.json: posthog-js; @segment/analytics-next
@@ -301,6 +322,7 @@ The plugin:
301
322
  - parses each `.vue` file with the real Vue compiler (`@vue/compiler-sfc`) into a single virtual TypeScript module combining `<script>`/`<script setup>`
302
323
  - extracts `track()` calls from the script exactly like a regular `.ts` file (direct calls, wrappers, propagation)
303
324
  - detects `event="feature_name"` attributes in `<template>` — both literal and dynamic (`:event="expr"`); dynamic bindings resolve through the same module scope as the script, or surface as a dynamic occurrence if unresolvable
325
+ - reads calls written anywhere in the template: `@click`/`v-on` handlers (several statements, multi-line arrows), `{{ }}` interpolations and the expressions of `v-bind`, `v-if`, `v-show`, `v-model`, `v-html`, `v-text`
304
326
  - Nuxt `.vue` files (pages/layouts/components) are handled the same way; auto-imported composables resolve once the project has run `nuxt prepare`/`nuxt dev`
305
327
 
306
328
  `sync.include` does **not** need `**/*.vue` manually — the plugin registers `**/*.vue` via `includeGlobs`. See [`@eventra_dev/cli-plugin-vue`](https://www.npmjs.com/package/@eventra_dev/cli-plugin-vue) for details.
@@ -321,6 +343,7 @@ The plugin:
321
343
 
322
344
  - parses each `.svelte` file with the real Svelte compiler (`svelte/compiler`) into a single virtual TypeScript module combining `context="module"` and instance `<script>` blocks
323
345
  - extracts `track()` calls from the script exactly like a regular `.ts` file (direct calls, wrappers, propagation); Svelte 5 runes (`$props`, `$state`, …) pass through untouched
346
+ - reads calls anywhere in the markup: handlers, `{expr}`, `{@html}`, block expressions (`{#if}`, `{#each}`, `{#key}`, `{#await}`), `use:`/`class:`/`transition:` directives and attribute values
324
347
  - detects `event="feature_name"` attributes on any tag — plain elements, components, `{#if}`/`{#each}`/`{#await}`/`{#key}` blocks, slots — both literal and dynamic (`event={expr}`); dynamic bindings resolve through the same module scope as the script, or surface as a dynamic occurrence if unresolvable
325
348
 
326
349
  `sync.include` does **not** need `**/*.svelte` manually. See [`@eventra_dev/cli-plugin-svelte`](https://www.npmjs.com/package/@eventra_dev/cli-plugin-svelte) for details.
@@ -341,6 +364,7 @@ The plugin:
341
364
 
342
365
  - parses each `.astro` file with the real Astro compiler (`@astrojs/compiler`) into a single virtual TypeScript module from the frontmatter
343
366
  - extracts `track()` calls from the frontmatter exactly like a regular `.ts` file (direct calls, wrappers, propagation); `Astro.props` and other globals pass through untouched
367
+ - reads calls in template expressions (`{track("x")}`, `attr={expr}`, `{...spread}`) and in the page's `<script>` tags, each as a module of its own (quoted `onclick="..."` strings are browser text and are not read)
344
368
  - detects `event="feature_name"` attributes on any tag, including inside JSX expressions (`{cond && <Button event="..." />}`, `.map()`) — both literal and dynamic (`event={expr}`, including the `{event}` shorthand); dynamic bindings resolve through the same module scope as the frontmatter, or surface as a dynamic occurrence if unresolvable
345
369
 
346
370
  `sync.include` does **not** need `**/*.astro` manually. See [`@eventra_dev/cli-plugin-astro`](https://www.npmjs.com/package/@eventra_dev/cli-plugin-astro) for details.
@@ -365,6 +389,7 @@ The plugin:
365
389
 
366
390
  - parses each component template with the real Angular compiler (`@angular/compiler`) into a virtual TypeScript module, paired with its component class (`foo.component.html` ↔ `foo.component.ts`, Angular CLI's default naming)
367
391
  - extracts `track()` calls from the component class exactly like a regular `.ts` file (direct calls, wrappers, propagation) - no plugin needed for that part
392
+ - reads calls in the template: `(click)` handlers, `[input]` bindings, `{{ }}` interpolations, `*ngIf` and `@if`/`@switch`/`@for`, written against the component class (`track('x')` is `this.track('x')`); an `.html` file no component names as its `templateUrl` is skipped when it does not parse
368
393
  - detects `event="feature_name"` attributes on any tag, including `*ngIf`/`*ngFor` and the `@if`/`@for`/`@switch` block syntax - both literal and dynamic; a dynamic binding resolves against the component instance (`this.field`, including getters), or surfaces as a dynamic occurrence if unresolvable
369
394
  - use `[attr.event]="expr"`, not `[event]="expr"`, for a dynamic binding on a plain HTML element - `[event]` is a property binding that Angular's real compiler rejects (`NG8002`) unless the element is a component that actually declares `@Input() event`; both forms are detected identically by the plugin, only `[attr.event]` is guaranteed to compile
370
395
 
@@ -426,7 +451,7 @@ No dependency on `@eventra_dev/eventra-cli` is required — the host CLI loads t
426
451
  | `plugins` | `@eventra_dev/cli-plugin-*` specifiers to `import()` at startup. Anything outside that scope is skipped (see [Plugin contract](#plugin-contract-for-authors)) |
427
452
  | `sinks` | Third-party analytics calls to treat as event sources, e.g. `[{ "preset": "posthog" }]` (see [Third-party analytics](#third-party-analytics-sinks)) |
428
453
  | `sync.include` | Base glob patterns; plugin `includeGlobs` are merged automatically |
429
- | `sync.exclude` | Paths skipped during scan. A plain name such as `node_modules` or `dist` matches at any depth (nested `packages/*/node_modules` too); entries containing `/` or glob characters are used as written |
454
+ | `sync.exclude` | Paths skipped during scan. Default for a config that has none: `node_modules`, `dist`, `.next`, `.git`. `eventra init` writes a longer list into a new `eventra.json`: that plus `coverage`, test files (`**/*.{test,spec}.{ts,tsx,js,jsx}`) and `__tests__`/`__mocks__` folders. A plain name such as `node_modules` or `dist` matches at any depth (nested `packages/*/node_modules` too); entries containing `/` or glob characters are used as written |
430
455
 
431
456
  `eventra init`/`eventra send` never write a real key into this file — they write to `eventra.local.json` instead, which is automatically added to `.gitignore`.
432
457
 
@@ -439,7 +464,7 @@ No dependency on `@eventra_dev/eventra-cli` is required — the host CLI loads t
439
464
  3. Run file preprocessors (e.g. `.vue` → virtual `.ts` modules).
440
465
  4. Load sources into an incremental TypeScript program (with SDK type shim).
441
466
  5. **Phase 1** — find `Eventra` instances from `@eventra_dev/eventra-sdk` and register function wrappers that call `.track()`.
442
- 6. **Phase 2** — resolve static event names and wrapper propagation chains (sink detector chain includes plugin sinks).
467
+ 6. **Phase 2** - resolve static event names and wrapper propagation chains, a wrapper that calls another wrapper included (sink detector chain includes plugin sinks).
443
468
  7. Write results to `eventra.json`.
444
469
 
445
470
  `watch` tracks **disk source files** (including `.vue`), re-runs preprocessors on change, and incrementally updates the engine.
@@ -457,7 +482,7 @@ No runtime execution. No monkey-patching.
457
482
 
458
483
  ## Test Coverage
459
484
 
460
- Two test layers, **783 unit tests + 14 e2e fixtures + 3 `check` exit-code scenarios + 1 `watch` scenario**.
485
+ Two test layers, **925 unit tests + 14 e2e fixtures + 3 `check` exit-code scenarios + 1 `watch` scenario**.
461
486
 
462
487
  **100% statement/branch/function/line coverage** (v8 provider, `pnpm test:coverage`), enforced via a `coverage.thresholds` block in `vitest.config.ts` so a regression fails the build instead of silently slipping under 100%. This measures the vitest unit layer only; the e2e/`check`/`watch` fixtures run against the compiled `dist/` binary via `tsx tests/run.ts` and aren't coverage-instrumented. A handful of genuinely unreachable defensive branches (TS-internal invariants — e.g. an aliased import symbol is always fully resolved before its own declarations are ever inspected — that don't hold a second outcome in practice) are marked with `/* v8 ignore */`, with an inline comment explaining why, rather than tested.
463
488
 
@@ -468,7 +493,10 @@ Two test layers, **783 unit tests + 14 e2e fixtures + 3 `check` exit-code scenar
468
493
  | `ImportGraph` | Forward/reverse edges, cycles, stale-edge cleanup, file removal |
469
494
  | `Scheduler` | Batch coalescing, last-write-wins per file, sequential bursts, error propagation |
470
495
  | `DocumentRegistry` | Path normalization, version bumping, no-op on identical content, `ensure()` from disk |
471
- | `CompilerContext` | Stage/update/remove files, `resolveModule` with `tsconfig.json` `paths`, source-file enumeration |
496
+ | `CompilerContext` | Stage/update/remove files, `resolveModule` with `tsconfig.json` `paths`, source-file enumeration, nearest `tsconfig.json` per file, `references`, workspace packages without an install |
497
+ | `wrapper chains` | A wrapper calling a wrapper (depth 2 and 3), either file order, generics, destructuring, cycles, watch updates |
498
+ | `hook wrappers` | `return { capture }`, dynamic `import()` receivers, `.then` callbacks, promise-typed getters |
499
+ | `respawn` | Heap sizing (a container's lower memory limit counts), only scanning commands respawn, signals reach the child, no second respawn, explicit `--max-old-space-size` respected |
472
500
  | `EventraEngine` | Direct calls, SDK isolation, cross-file wrappers, file updates, file removal, wrapper filtering |
473
501
  | `PluginRegistry` | Built-in SDK sink, preprocessors, virtual-path mapping, include-pattern dedup |
474
502
  | `external plugin adapter` | Transform output mapping, static sink registration, invalid result rejection |
@@ -9,7 +9,7 @@ const exportResolver_1 = require("./exportResolver");
9
9
  function isFunctionLike(node) {
10
10
  return (typescript_1.default.isFunctionDeclaration(node) || typescript_1.default.isMethodDeclaration(node) || typescript_1.default.isArrowFunction(node) || typescript_1.default.isFunctionExpression(node));
11
11
  }
12
- function resolveDeclaration(declaration) {
12
+ function resolveDeclaration(declaration, checker) {
13
13
  // function foo() {}
14
14
  if (isFunctionLike(declaration)) {
15
15
  return declaration;
@@ -26,6 +26,20 @@ function resolveDeclaration(declaration) {
26
26
  if (typescript_1.default.isPropertyAssignment(declaration) && declaration.initializer && isFunctionLike(declaration.initializer)) {
27
27
  return declaration.initializer;
28
28
  }
29
+ // `return { capture }` / `return { capture: capture }`: a hook or factory that hands back functions
30
+ // it defined, so `analytics.capture(...)` is a call of the `capture` declared next to the return
31
+ const target = typescript_1.default.isShorthandPropertyAssignment(declaration)
32
+ ? checker.getShorthandAssignmentValueSymbol(declaration)
33
+ : typescript_1.default.isPropertyAssignment(declaration) && typescript_1.default.isIdentifier(declaration.initializer)
34
+ ? checker.getSymbolAtLocation(declaration.initializer)
35
+ : undefined;
36
+ // the target is a variable, never another property, so this does not recurse into a cycle
37
+ for (const next of target?.getDeclarations() ?? []) {
38
+ const resolved = resolveDeclaration(next, checker);
39
+ if (resolved) {
40
+ return resolved;
41
+ }
42
+ }
29
43
  return null;
30
44
  }
31
45
  function getExpressionSymbol(expression, checker) {
@@ -69,7 +83,7 @@ function resolveFunctionFromCall(expression, checker, callCache, exportCache) {
69
83
  // declaration resolution
70
84
  const declarations = symbol.getDeclarations() ?? [];
71
85
  for (const declaration of declarations) {
72
- const resolved = resolveDeclaration(declaration);
86
+ const resolved = resolveDeclaration(declaration, checker);
73
87
  if (!resolved) {
74
88
  continue;
75
89
  }
@@ -101,5 +101,6 @@ function scanSource(source, checker, wrapperRegistry, plugins) {
101
101
  sinks,
102
102
  trackCalls,
103
103
  wrappers,
104
+ functions,
104
105
  };
105
106
  }
@@ -0,0 +1,115 @@
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.resolveWrapperChains = resolveWrapperChains;
7
+ const typescript_1 = __importDefault(require("typescript"));
8
+ const callResolver_1 = require("../resolver/callResolver");
9
+ const propagationAnalyzer_1 = require("./analyzer/propagationAnalyzer");
10
+ /**
11
+ * Each round can only discover wrappers one call deeper than the previous one, so this is also the
12
+ * longest wrapper chain that is followed (`track` -> `capture` -> `posthog.capture` is two rounds).
13
+ * Real chains are two or three deep; the bound mostly stops a cycle from spinning.
14
+ */
15
+ const MAX_ROUNDS = 8;
16
+ /** Every call inside `fn`, nested functions included (the same reach the direct sinks have). */
17
+ function callsWithin(fn) {
18
+ const calls = [];
19
+ const visit = (node) => {
20
+ if (typescript_1.default.isCallExpression(node))
21
+ calls.push(node);
22
+ typescript_1.default.forEachChild(node, visit);
23
+ };
24
+ /* v8 ignore else -- only functions with a body are collected */
25
+ if (fn.body)
26
+ visit(fn.body);
27
+ return calls;
28
+ }
29
+ /**
30
+ * A call of an already known wrapper is, for the function containing it, the same thing a call of a
31
+ * real sink is: the arguments the wrapper forwards to its sink are the ones to follow.
32
+ *
33
+ * Only a plain parameter (`event`) or an object literal read by one property (`{ event }`) can be
34
+ * followed: a wrapper that reads `options.event` from a whole object has no argument in the caller
35
+ * to point at, so it is not chained rather than guessed.
36
+ */
37
+ function wrapperCallAsSink(call, self, checker, registry, callCache, exportCache) {
38
+ const callee = (0, callResolver_1.resolveFunctionFromCall)(call.expression, checker, callCache, exportCache);
39
+ if (!callee || callee === self)
40
+ return null;
41
+ const wrapper = registry.get(callee);
42
+ if (!wrapper)
43
+ return null;
44
+ const trackedArguments = wrapper.propagations
45
+ .filter((p) => {
46
+ const argument = call.arguments[p.sourceParameterIndex];
47
+ if (!argument)
48
+ return false;
49
+ return p.propertyPath.length === 0 || typescript_1.default.isObjectLiteralExpression(argument);
50
+ })
51
+ .map((p) => ({ index: p.sourceParameterIndex, propertyPath: p.propertyPath }));
52
+ return trackedArguments.length > 0 ? { call, trackedArguments } : null;
53
+ }
54
+ const sameNode = (a, b) => a.targetNode === b.targetNode &&
55
+ a.sourceParameterIndex === b.sourceParameterIndex &&
56
+ a.propertyPath.join("\0") === b.propertyPath.join("\0");
57
+ /**
58
+ * Finds the wrappers that only reach a sink through another wrapper (`trackEvent(event)` calls
59
+ * `capture(event)`, which calls `posthog.capture(event)`), which scanning one file at a time cannot:
60
+ * the inner wrapper may live in a file that has not been indexed yet. Repeats over every file until
61
+ * a round finds nothing new, registers what it finds, and returns the files whose wrapper list grew.
62
+ */
63
+ function resolveWrapperChains(indices, checker, registry, callCache, exportCache) {
64
+ const found = new Map();
65
+ const files = [...indices].filter((index) => (index.functions?.length ?? 0) > 0);
66
+ // the calls of a function never change between rounds, so they are collected once; a function with
67
+ // none (most of them) costs nothing in the rounds that follow
68
+ const callsOf = new Map();
69
+ for (const index of files) {
70
+ for (const fn of index.functions) {
71
+ if (fn.body) {
72
+ const calls = callsWithin(fn);
73
+ if (calls.length > 0)
74
+ callsOf.set(fn, calls);
75
+ }
76
+ }
77
+ }
78
+ for (let round = 0; round < MAX_ROUNDS; round++) {
79
+ let changed = false;
80
+ for (const index of files) {
81
+ for (const fn of index.functions) {
82
+ const calls = callsOf.get(fn);
83
+ if (!calls)
84
+ continue;
85
+ const sinks = [];
86
+ for (const call of calls) {
87
+ const sink = wrapperCallAsSink(call, fn, checker, registry, callCache, exportCache);
88
+ if (sink)
89
+ sinks.push(sink);
90
+ }
91
+ if (sinks.length === 0)
92
+ continue;
93
+ const chained = (0, propagationAnalyzer_1.analyzeWrapperPropagation)(fn, checker, sinks);
94
+ if (!chained)
95
+ continue;
96
+ const known = registry.get(fn);
97
+ const added = chained.propagations.filter((p) => !known?.propagations.some((k) => sameNode(k, p)));
98
+ if (added.length === 0)
99
+ continue;
100
+ const merged = {
101
+ symbol: chained.symbol,
102
+ declaration: fn,
103
+ propagations: [...(known?.propagations ?? []), ...added],
104
+ };
105
+ registry.set(merged);
106
+ const list = found.get(index.fileName) ?? [];
107
+ found.set(index.fileName, [...list.filter((w) => w.declaration !== fn), merged]);
108
+ changed = true;
109
+ }
110
+ }
111
+ if (!changed)
112
+ break;
113
+ }
114
+ return found;
115
+ }
@@ -4,7 +4,7 @@ exports.EVENTRA_SDK_SHIM = exports.MAX_EVENT_NAME_LENGTH = exports.EVENTRA_SDK_P
4
4
  exports.isEventraSdkModuleSpecifier = isEventraSdkModuleSpecifier;
5
5
  exports.normalizeEventName = normalizeEventName;
6
6
  exports.isValidEventName = isValidEventName;
7
- /** Mirrors @eventra_dev/eventra-sdk runtime limits (see packages/sdk/src/client.ts). */
7
+ /** Mirrors the length limit of @eventra_dev/eventra-sdk (see packages/sdk/src/client.ts). */
8
8
  exports.EVENTRA_SDK_PACKAGE = "@eventra_dev/eventra-sdk";
9
9
  exports.MAX_EVENT_NAME_LENGTH = 64;
10
10
  function isEventraSdkModuleSpecifier(specifier) {
@@ -13,10 +13,12 @@ function isEventraSdkModuleSpecifier(specifier) {
13
13
  normalized.startsWith(`${exports.EVENTRA_SDK_PACKAGE}/`));
14
14
  }
15
15
  /**
16
- * Letters, digits and `: _ . / -`, plus the plain space - so third-party names such as
17
- * `App: User Sign Up` survive. Tabs, newlines and every other character stay rejected.
16
+ * Letters, digits and `$ : _ . / -`, plus the plain space - so third-party names such as
17
+ * `App: User Sign Up` and PostHog's own `$pageview` survive. The SDK and the API accept any text up
18
+ * to the length limit; the CLI stays stricter so that a name read out of code is a clean identifier:
19
+ * tabs, newlines and every other character are rejected.
18
20
  */
19
- const EVENT_NAME_PATTERN = /^[a-zA-Z0-9:_./ -]+$/;
21
+ const EVENT_NAME_PATTERN = /^[a-zA-Z0-9$:_./ -]+$/;
20
22
  function normalizeEventName(value) {
21
23
  const trimmed = value.trim();
22
24
  if (!trimmed || trimmed.length > exports.MAX_EVENT_NAME_LENGTH) {
package/dist/cli/init.js CHANGED
@@ -3,10 +3,12 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.isInstalled = isInstalled;
6
7
  exports.init = init;
7
8
  const chalk_1 = __importDefault(require("chalk"));
8
9
  const fs_extra_1 = __importDefault(require("fs-extra"));
9
10
  const inquirer_1 = __importDefault(require("inquirer"));
11
+ const module_1 = require("module");
10
12
  const path_1 = __importDefault(require("path"));
11
13
  const config_1 = require("../config/config");
12
14
  const sinkPresets_1 = require("../plugin/builtins/sinkPresets");
@@ -15,8 +17,8 @@ const projectDependencies_1 = require("./projectDependencies");
15
17
  * Looks for analytics libraries in package.json (and workspace members) and, if there are any,
16
18
  * asks which of them Eventra should read events from. Everything detected is pre-selected.
17
19
  */
18
- async function chooseSinks(yes) {
19
- const detected = (0, sinkPresets_1.detectSinkPresets)(await (0, projectDependencies_1.collectDependencyNames)(process.cwd()));
20
+ async function chooseSinks(yes, dependencyNames) {
21
+ const detected = (0, sinkPresets_1.detectSinkPresets)(dependencyNames);
20
22
  if (detected.length === 0)
21
23
  return [];
22
24
  console.log(chalk_1.default.gray(`Found analytics in package.json: ${detected.map((d) => d.packages.join(", ")).join("; ")}`));
@@ -36,12 +38,40 @@ async function chooseSinks(yes) {
36
38
  ]);
37
39
  return presets.map((preset) => ({ preset }));
38
40
  }
41
+ /** Framework dependency -> the plugin that reads its template files (`.vue`, `.svelte`, `.astro`, Angular templates). */
42
+ const FRAMEWORK_PLUGINS = {
43
+ vue: "@eventra_dev/cli-plugin-vue",
44
+ svelte: "@eventra_dev/cli-plugin-svelte",
45
+ astro: "@eventra_dev/cli-plugin-astro",
46
+ "@angular/core": "@eventra_dev/cli-plugin-angular",
47
+ };
48
+ /** True when the plugin package can be loaded from the project, which is where `sync` will look for it. */
49
+ function isInstalled(plugin) {
50
+ try {
51
+ (0, module_1.createRequire)(path_1.default.join(process.cwd(), "package.json")).resolve(plugin);
52
+ return true;
53
+ }
54
+ catch {
55
+ return false;
56
+ }
57
+ }
58
+ /**
59
+ * The plugins for the frameworks found in package.json (and workspace members), split by whether they are
60
+ * installed. Without a plugin, events fired from component files are never seen. Only an installed one is
61
+ * written to `plugins`: `sync` stops on a plugin it cannot load, and an `init` followed by `sync` must not.
62
+ */
63
+ function detectPlugins(dependencyNames, installed) {
64
+ const found = Object.entries(FRAMEWORK_PLUGINS)
65
+ .filter(([dependency]) => dependencyNames.has(dependency))
66
+ .map(([, plugin]) => plugin);
67
+ return { enabled: found.filter(installed), missing: found.filter((plugin) => !installed(plugin)) };
68
+ }
39
69
  const DEFAULT_ENDPOINT = "https://api.eventra.dev/api/v1/cli/events";
40
70
  /**
41
71
  * `yes` is the non-interactive mode (CI, scripts): defaults for every answer,
42
72
  * and an existing eventra.json is never overwritten.
43
73
  */
44
- async function init({ yes = false } = {}) {
74
+ async function init({ yes = false, isPluginInstalled = isInstalled } = {}) {
45
75
  if (await fs_extra_1.default.pathExists(path_1.default.join(process.cwd(), config_1.CONFIG_NAME))) {
46
76
  if (yes) {
47
77
  console.log(chalk_1.default.yellow(`${config_1.CONFIG_NAME} already exists, keeping it`));
@@ -76,7 +106,9 @@ async function init({ yes = false } = {}) {
76
106
  default: DEFAULT_ENDPOINT,
77
107
  },
78
108
  ]);
79
- const sinks = await chooseSinks(yes);
109
+ const dependencyNames = await (0, projectDependencies_1.collectDependencyNames)(process.cwd());
110
+ const sinks = await chooseSinks(yes, dependencyNames);
111
+ const { enabled: plugins, missing: missingPlugins } = detectPlugins(dependencyNames, isPluginInstalled);
80
112
  console.log("");
81
113
  console.log(chalk_1.default.gray("Eventra detects @eventra_dev/eventra-sdk usage:"));
82
114
  console.log(chalk_1.default.gray('• tracker.track("event.name")'));
@@ -88,21 +120,24 @@ async function init({ yes = false } = {}) {
88
120
  endpoint,
89
121
  events: [],
90
122
  functionWrappers: [],
123
+ ...(plugins.length > 0 ? { plugins } : {}),
91
124
  ...(sinks.length > 0 ? { sinks } : {}),
92
125
  sync: {
93
126
  include: [
94
127
  "**/*.{ts,tsx,js,jsx}",
95
128
  ],
96
- exclude: [
97
- "node_modules",
98
- "dist",
99
- ".next",
100
- ".git",
101
- ],
129
+ exclude: [...config_1.INIT_EXCLUDE],
102
130
  },
103
131
  };
104
132
  await (0, config_1.saveConfig)(config);
105
133
  console.log(chalk_1.default.green("eventra.json created"));
134
+ if (plugins.length > 0) {
135
+ console.log(chalk_1.default.green(`plugins: ${plugins.join(", ")}`));
136
+ }
137
+ if (missingPlugins.length > 0) {
138
+ console.log(chalk_1.default.yellow(`Component files are not read yet: npm install -D ${missingPlugins.join(" ")}`));
139
+ console.log(chalk_1.default.yellow(`then add ${missingPlugins.map((p) => `"${p}"`).join(", ")} to "plugins" in ${config_1.CONFIG_NAME}`));
140
+ }
106
141
  if (sinks.length > 0) {
107
142
  console.log(chalk_1.default.green(`sinks: ${sinks.map((s) => s.preset).join(", ")}`));
108
143
  }
@@ -3,13 +3,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.memberGlob = exports.MAX_WORKSPACE_PACKAGES = void 0;
7
+ exports.stringList = stringList;
8
+ exports.pnpmWorkspacePatterns = pnpmWorkspacePatterns;
9
+ exports.staysInsideProject = staysInsideProject;
6
10
  exports.collectDependencyNames = collectDependencyNames;
7
11
  const fast_glob_1 = __importDefault(require("fast-glob"));
8
12
  const fs_extra_1 = __importDefault(require("fs-extra"));
9
13
  const path_1 = __importDefault(require("path"));
10
14
  const DEPENDENCY_FIELDS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
11
15
  /** Upper bound on workspace members read, so a huge monorepo cannot make `init` crawl. */
12
- const MAX_WORKSPACE_PACKAGES = 300;
16
+ exports.MAX_WORKSPACE_PACKAGES = 300;
13
17
  /**
14
18
  * Parsed package.json, or null when it is missing or not JSON. Whatever else the file holds (an
15
19
  * array, a number) is returned as is: every reader below only looks up named properties on it, which
@@ -86,10 +90,11 @@ async function workspacePatterns(rootDir, rootPackage) {
86
90
  * `!apps/skipped/package.json`, which fast-glob treats as an exclusion of exactly those files.
87
91
  */
88
92
  const memberGlob = (pattern) => `${pattern}/package.json`;
93
+ exports.memberGlob = memberGlob;
89
94
  /** Member package.json paths for the patterns; a pattern fast-glob rejects (a NUL byte, say) yields none. */
90
95
  async function findMembers(rootDir, patterns) {
91
96
  try {
92
- return await (0, fast_glob_1.default)(patterns.map(memberGlob), { cwd: rootDir, ignore: ["**/node_modules/**"] });
97
+ return await (0, fast_glob_1.default)(patterns.map(exports.memberGlob), { cwd: rootDir, ignore: ["**/node_modules/**"] });
93
98
  }
94
99
  catch {
95
100
  // Stryker disable next-line ArrayDeclaration: a stray path that is not a package.json is read as "no package.json"
@@ -108,7 +113,7 @@ async function collectDependencyNames(rootDir) {
108
113
  for (const name of dependencyNames(rootPackage))
109
114
  names.add(name);
110
115
  const patterns = await workspacePatterns(rootDir, rootPackage);
111
- const members = (await findMembers(rootDir, patterns)).slice(0, MAX_WORKSPACE_PACKAGES);
116
+ const members = (await findMembers(rootDir, patterns)).slice(0, exports.MAX_WORKSPACE_PACKAGES);
112
117
  for (const member of members) {
113
118
  const memberPackage = await readPackage(path_1.default.join(rootDir, member));
114
119
  if (memberPackage !== null)
@@ -8,6 +8,7 @@ const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
9
  const typescript_1 = __importDefault(require("typescript"));
10
10
  const documentRegistry_1 = require("./documentRegistry");
11
+ const workspacePackages_1 = require("./workspacePackages");
11
12
  class CompilerContext {
12
13
  rootDir;
13
14
  registry = new documentRegistry_1.DocumentRegistry();
@@ -22,9 +23,15 @@ class CompilerContext {
22
23
  // scan and flushes once, so an eager build in the constructor was thrown away.
23
24
  programInstance;
24
25
  rootNames = new Set();
25
- // Only set while withResolutionCache() runs: it memoises fileExists results,
26
- // so it must never outlive one batch of resolutions against one program.
27
- activeResolutionCache;
26
+ // compiler options of the nearest tsconfig.json above a folder (the root one for files outside any other)
27
+ optionsByDir = new Map();
28
+ // the options a project starts from before any tsconfig.json is applied
29
+ defaultOptions;
30
+ workspacePackages;
31
+ // one module resolution cache per set of options; only alive while a program is built or a batch runs
32
+ resolutionCaches = new Map();
33
+ // how many builds or batches are running that share the caches (a rebuild can start inside a batch)
34
+ cachingDepth = 0;
28
35
  constructor(rootDir) {
29
36
  this.rootDir = rootDir;
30
37
  const localTsconfig = path_1.default.join(rootDir, "tsconfig.json");
@@ -45,6 +52,7 @@ class CompilerContext {
45
52
  allowNonTsExtensions: true,
46
53
  incremental: false,
47
54
  };
55
+ this.defaultOptions = compilerOptions;
48
56
  if (configPath) {
49
57
  const config = typescript_1.default.readConfigFile(configPath, typescript_1.default.sys.readFile);
50
58
  if (config.error) {
@@ -55,17 +63,138 @@ class CompilerContext {
55
63
  ...compilerOptions,
56
64
  ...parsed.options,
57
65
  };
66
+ // A solution-style tsconfig (`"files": []` plus `references`, the Vite and Vue template) keeps its
67
+ // `@/*` aliases in a referenced config, so without this every aliased import stays unresolved.
68
+ if (!parsed.options.paths) {
69
+ Object.assign(compilerOptions, this.pathOptionsFromReferences(parsed.projectReferences));
70
+ }
58
71
  for (const file of parsed.fileNames) {
59
72
  this.rootNames.add(this.normalize(file));
60
73
  }
61
74
  }
62
75
  this.compilerOptions = compilerOptions;
76
+ this.workspacePackages = (0, workspacePackages_1.findWorkspacePackages)(rootDir);
63
77
  this.host = typescript_1.default.createCompilerHost(this.compilerOptions, true);
64
78
  this.patchCompilerHost();
65
79
  }
66
80
  normalize(fileName) {
67
81
  return this.registry.normalize(fileName);
68
82
  }
83
+ /**
84
+ * Compiler options for resolving the imports of `fromFile`: those of the nearest tsconfig.json above
85
+ * it. A monorepo keeps one per app (`@/*` means a different folder in each) and often none at the root;
86
+ * a single root-level tsconfig.json answers for every file below it, as it always did.
87
+ */
88
+ optionsFor(fromFile) {
89
+ // an installed package may ship a tsconfig.json of its own: its settings are for building it, not for this project
90
+ if (fromFile.split(/[\\/]/).includes("node_modules"))
91
+ return this.compilerOptions;
92
+ const visited = [];
93
+ let options = this.compilerOptions;
94
+ for (let dir = path_1.default.dirname(fromFile);; dir = path_1.default.dirname(dir)) {
95
+ const cached = this.optionsByDir.get(dir);
96
+ const own = cached ?? this.readDirectoryOptions(dir);
97
+ if (own) {
98
+ options = own;
99
+ visited.push(dir);
100
+ break;
101
+ }
102
+ visited.push(dir);
103
+ // a file outside the project, or the top of the filesystem, keeps the root options
104
+ if (path_1.default.relative(this.rootDir, dir).startsWith("..") || path_1.default.dirname(dir) === dir || dir === this.rootDir)
105
+ break;
106
+ }
107
+ for (const dir of visited)
108
+ this.optionsByDir.set(dir, options);
109
+ return options;
110
+ }
111
+ /** The options a folder's own tsconfig.json sets, `undefined` when it has none (the root's are already loaded). */
112
+ readDirectoryOptions(dir) {
113
+ if (dir === this.rootDir)
114
+ return this.compilerOptions;
115
+ const configPath = path_1.default.join(dir, "tsconfig.json");
116
+ if (!fs_1.default.existsSync(configPath))
117
+ return undefined;
118
+ const config = typescript_1.default.readConfigFile(configPath, typescript_1.default.sys.readFile);
119
+ if (config.error)
120
+ return undefined;
121
+ const parsed = typescript_1.default.parseJsonConfigFileContent(config.config, typescript_1.default.sys, dir);
122
+ return {
123
+ ...this.defaultOptions,
124
+ ...parsed.options,
125
+ ...(parsed.options.paths ? {} : this.aliasesWithout(parsed.options, parsed.projectReferences)),
126
+ };
127
+ }
128
+ /**
129
+ * Aliases for a config that sets no `paths` of its own: those of its project references, or else the root's.
130
+ * A tsconfig.json that only adjusts a few options (a `tests/` one, say) does not mean the root's `@/*` stopped
131
+ * being valid there, and the root's aliases applied to every file before configs were read per folder.
132
+ */
133
+ aliasesWithout(own, references) {
134
+ const fromReferences = this.pathOptionsFromReferences(references);
135
+ if (fromReferences.paths)
136
+ return fromReferences;
137
+ const { paths, baseUrl, pathsBasePath } = this.compilerOptions;
138
+ return { paths, baseUrl: own.baseUrl ?? baseUrl, pathsBasePath };
139
+ }
140
+ cacheFor(options) {
141
+ if (this.cachingDepth === 0)
142
+ return undefined;
143
+ let cache = this.resolutionCaches.get(options);
144
+ if (!cache) {
145
+ cache = typescript_1.default.createModuleResolutionCache(this.rootDir, (fileName) => this.normalize(fileName), options);
146
+ this.resolutionCaches.set(options, cache);
147
+ }
148
+ return cache;
149
+ }
150
+ /** Where a monorepo package's own folder is for an import of it (`@acme/lib`, `@acme/lib/hooks/x`), if it is one. */
151
+ workspaceTargets(moduleName) {
152
+ let best;
153
+ for (const [name, dir] of this.workspacePackages) {
154
+ if ((moduleName === name || moduleName.startsWith(`${name}/`)) && (!best || name.length > best.name.length)) {
155
+ best = { name, dir };
156
+ }
157
+ }
158
+ if (!best)
159
+ return [];
160
+ const sub = moduleName.slice(best.name.length + 1);
161
+ // the package's sources may sit in src/ while its package.json points at a build that is not there
162
+ return [path_1.default.join(best.dir, sub), path_1.default.join(best.dir, "src", sub)];
163
+ }
164
+ resolveImport(moduleName, containingFile, redirectedReference, mode) {
165
+ const options = this.optionsFor(containingFile);
166
+ const cache = this.cacheFor(options);
167
+ const resolved = typescript_1.default.resolveModuleName(moduleName, containingFile, options, this.host, cache, redirectedReference, mode);
168
+ if (resolved.resolvedModule)
169
+ return resolved;
170
+ // not installed, so no node_modules link: a package of this very monorepo is found by its folder
171
+ for (const target of this.workspaceTargets(moduleName)) {
172
+ const viaWorkspace = typescript_1.default.resolveModuleName(target, containingFile, options, this.host, cache, redirectedReference, mode);
173
+ if (viaWorkspace.resolvedModule)
174
+ return viaWorkspace;
175
+ }
176
+ return resolved;
177
+ }
178
+ /**
179
+ * Alias options (`paths`, `baseUrl`) of the first referenced tsconfig that defines `paths`. A
180
+ * reference that is unreadable or has no aliases is skipped; only aliases are taken, never the files.
181
+ */
182
+ pathOptionsFromReferences(references) {
183
+ for (const reference of references ?? []) {
184
+ const referenced = typescript_1.default.sys.directoryExists(reference.path)
185
+ ? path_1.default.join(reference.path, "tsconfig.json")
186
+ : reference.path;
187
+ const config = typescript_1.default.readConfigFile(referenced, typescript_1.default.sys.readFile);
188
+ if (config.error)
189
+ continue;
190
+ const { options } = typescript_1.default.parseJsonConfigFileContent(config.config, typescript_1.default.sys, path_1.default.dirname(referenced));
191
+ if (options.paths) {
192
+ const { paths, baseUrl, pathsBasePath } = options;
193
+ return { paths, baseUrl, pathsBasePath };
194
+ }
195
+ }
196
+ return {};
197
+ }
69
198
  resolveLanguageVersion(languageVersionOrOptions) {
70
199
  if (typeof languageVersionOrOptions === "object") {
71
200
  return languageVersionOrOptions.languageVersion;
@@ -127,6 +256,7 @@ class CompilerContext {
127
256
  const normalized = this.normalize(fileName);
128
257
  return this.registry.has(normalized) || fs_1.default.existsSync(normalized);
129
258
  };
259
+ this.host.resolveModuleNameLiterals = (literals, containingFile, redirectedReference, _options, containingSourceFile) => literals.map((literal) => this.resolveImport(literal.text, containingFile, redirectedReference, typescript_1.default.getModeForUsageLocation(containingSourceFile, literal, this.compilerOptions)));
130
260
  this.host.getCanonicalFileName = (f) => this.normalize(f);
131
261
  this.host.useCaseSensitiveFileNames = () => true;
132
262
  this.host.getCurrentDirectory = () => this.rootDir;
@@ -146,7 +276,29 @@ class CompilerContext {
146
276
  return typescript_1.default.ScriptKind.Unknown;
147
277
  }
148
278
  rebuildProgram() {
149
- this.programInstance = typescript_1.default.createProgram([...this.rootNames], this.compilerOptions, this.host);
279
+ // options are re-read from disk with every build, so an edited nested tsconfig.json is seen
280
+ this.optionsByDir.clear();
281
+ this.withSharedCaches(() => {
282
+ this.programInstance = typescript_1.default.createProgram([...this.rootNames], this.compilerOptions, this.host);
283
+ });
284
+ }
285
+ /**
286
+ * Imports resolved while `fn` runs share caches, as they do in TypeScript's own host. Files may appear or
287
+ * go between runs, so nothing is kept after the outermost one; a run started inside another (a lazy
288
+ * program build inside a batch) joins it instead of dropping its caches.
289
+ */
290
+ withSharedCaches(fn) {
291
+ if (this.cachingDepth === 0)
292
+ this.resolutionCaches.clear();
293
+ this.cachingDepth++;
294
+ try {
295
+ return fn();
296
+ }
297
+ finally {
298
+ this.cachingDepth--;
299
+ if (this.cachingDepth === 0)
300
+ this.resolutionCaches.clear();
301
+ }
150
302
  }
151
303
  get program() {
152
304
  if (!this.programInstance) {
@@ -160,16 +312,7 @@ class CompilerContext {
160
312
  * same directory lookups. The cache is dropped when `fn` returns.
161
313
  */
162
314
  withResolutionCache(fn) {
163
- if (this.activeResolutionCache) {
164
- return fn();
165
- }
166
- this.activeResolutionCache = typescript_1.default.createModuleResolutionCache(this.rootDir, (fileName) => this.normalize(fileName), this.compilerOptions);
167
- try {
168
- return fn();
169
- }
170
- finally {
171
- this.activeResolutionCache = undefined;
172
- }
315
+ return this.withSharedCaches(fn);
173
316
  }
174
317
  /** Register file content without rebuilding the TS program (batch via {@link flushUpdates}). */
175
318
  stageFile(fileName, content) {
@@ -271,8 +414,7 @@ class CompilerContext {
271
414
  ];
272
415
  }
273
416
  resolveModule(moduleName, fromFile) {
274
- const resolved = typescript_1.default.resolveModuleName(moduleName, this.normalize(fromFile), this.compilerOptions, this.host, this.activeResolutionCache);
275
- return resolved.resolvedModule?.resolvedFileName;
417
+ return this.resolveImport(moduleName, this.normalize(fromFile)).resolvedModule?.resolvedFileName;
276
418
  }
277
419
  getResolvedModules(fileName) {
278
420
  const source = this.getSourceFile(fileName);
@@ -0,0 +1,58 @@
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.findWorkspacePackages = findWorkspacePackages;
7
+ const fast_glob_1 = __importDefault(require("fast-glob"));
8
+ const fs_1 = __importDefault(require("fs"));
9
+ const path_1 = __importDefault(require("path"));
10
+ const projectDependencies_1 = require("../cli/projectDependencies");
11
+ function readJson(file) {
12
+ try {
13
+ return JSON.parse(fs_1.default.readFileSync(file, "utf8"));
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ /**
20
+ * The packages of a monorepo (`workspaces` in package.json, or pnpm-workspace.yaml) by name, mapped to
21
+ * their folder. `import "@acme/lib/hooks/x"` then resolves to a folder of the repo itself, so a project
22
+ * whose dependencies are not installed still links its own packages together.
23
+ */
24
+ function findWorkspacePackages(rootDir) {
25
+ const packages = new Map();
26
+ const rootPackage = readJson(path_1.default.join(rootDir, "package.json"));
27
+ if (!rootPackage)
28
+ return packages;
29
+ const workspaces = rootPackage.workspaces;
30
+ const fromPackageJson = Array.isArray(workspaces)
31
+ ? (0, projectDependencies_1.stringList)(workspaces)
32
+ : (0, projectDependencies_1.stringList)(workspaces?.packages);
33
+ let fromPnpm = [];
34
+ try {
35
+ fromPnpm = (0, projectDependencies_1.pnpmWorkspacePatterns)(fs_1.default.readFileSync(path_1.default.join(rootDir, "pnpm-workspace.yaml"), "utf8"));
36
+ }
37
+ catch {
38
+ // no pnpm-workspace.yaml (or an unreadable one): the package.json patterns are all there is
39
+ }
40
+ const patterns = [...fromPackageJson, ...fromPnpm].filter(projectDependencies_1.staysInsideProject);
41
+ if (patterns.length === 0)
42
+ return packages;
43
+ let members;
44
+ try {
45
+ members = fast_glob_1.default.sync(patterns.map(projectDependencies_1.memberGlob), { cwd: rootDir, ignore: ["**/node_modules/**"] });
46
+ }
47
+ catch {
48
+ // a pattern fast-glob rejects: no members
49
+ return packages;
50
+ }
51
+ for (const member of members.slice(0, projectDependencies_1.MAX_WORKSPACE_PACKAGES)) {
52
+ const name = readJson(path_1.default.join(rootDir, member))?.name;
53
+ if (typeof name === "string" && name !== "" && !packages.has(name)) {
54
+ packages.set(name, path_1.default.dirname(path_1.default.join(rootDir, member)));
55
+ }
56
+ }
57
+ return packages;
58
+ }
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.LOCAL_CONFIG_NAME = exports.CONFIG_NAME = void 0;
6
+ exports.INIT_EXCLUDE = exports.LOCAL_CONFIG_NAME = exports.CONFIG_NAME = void 0;
7
7
  exports.normalizeConfig = normalizeConfig;
8
8
  exports.loadConfig = loadConfig;
9
9
  exports.describeConfigProblem = describeConfigProblem;
@@ -22,7 +22,22 @@ exports.CONFIG_NAME = "eventra.json";
22
22
  */
23
23
  exports.LOCAL_CONFIG_NAME = "eventra.local.json";
24
24
  const DEFAULT_INCLUDE = ["**/*.{ts,tsx,js,jsx}"];
25
+ // what a config without `sync.exclude` has always skipped; changing it would change the scan of every
26
+ // existing project that never wrote its own list
25
27
  const DEFAULT_EXCLUDE = ["node_modules", "dist", ".next", ".git"];
28
+ /**
29
+ * What `eventra init` writes into a new eventra.json: the default above plus folders and files whose
30
+ * code is not the product. Events fired from a test are not features, and on a large repository the tests
31
+ * alone can double the files scanned. Written out, so it is visible and editable. `build` and `out` are
32
+ * left out on purpose: they are as likely to be a source folder as output.
33
+ */
34
+ exports.INIT_EXCLUDE = [
35
+ ...DEFAULT_EXCLUDE,
36
+ "coverage",
37
+ "**/*.{test,spec}.{ts,tsx,js,jsx}",
38
+ "**/__tests__/**",
39
+ "**/__mocks__/**",
40
+ ];
26
41
  function normalizeSinks(sinks) {
27
42
  if (!Array.isArray(sinks))
28
43
  return [];
@@ -14,6 +14,7 @@ const resolvedCallCache_1 = require("../analysis/cache/resolvedCallCache");
14
14
  const resolvedExportCache_1 = require("../analysis/cache/resolvedExportCache");
15
15
  const returnPropagationCache_1 = require("../analysis/cache/returnPropagationCache");
16
16
  const analyzeFile_1 = require("../analysis/engine/analyzeFile");
17
+ const wrapperChains_1 = require("../analysis/scanner/wrapperChains");
17
18
  const wrapperRegistry_1 = require("../analysis/symbols/wrapperRegistry");
18
19
  const loadPlugins_1 = require("../plugin/loadPlugins");
19
20
  const EMPTY_RESULT = () => ({
@@ -117,6 +118,17 @@ class EventraEngine {
117
118
  for (const sf of sources) {
118
119
  this.indexFile(sf.fileName, config);
119
120
  }
121
+ this.resolveWrapperChains();
122
+ }
123
+ // Wrappers that reach a sink only through another wrapper (`trackEvent` -> `capture` -> the SDK
124
+ // call) need every file indexed first, so this runs after the files are indexed, never per file.
125
+ resolveWrapperChains() {
126
+ const found = (0, wrapperChains_1.resolveWrapperChains)(this.fileIndices.values(), this.compiler.getChecker(), this.wrapperRegistry, this.resolvedCallCache, this.resolvedExportCache);
127
+ for (const [file, wrappers] of found) {
128
+ const index = this.fileIndices.get(file);
129
+ const kept = index.wrappers.filter((w) => !wrappers.some((n) => n.declaration === w.declaration));
130
+ this.fileIndices.set(file, { ...index, wrappers: [...kept, ...wrappers] });
131
+ }
120
132
  }
121
133
  beginPreload() {
122
134
  this.isPreloading = true;
@@ -213,6 +225,7 @@ class EventraEngine {
213
225
  this.scannedFiles.add(file);
214
226
  this.indexFile(file, config);
215
227
  }
228
+ this.resolveWrapperChains();
216
229
  for (const file of normalized) {
217
230
  this.extractFile(file, config);
218
231
  }
@@ -310,6 +323,7 @@ class EventraEngine {
310
323
  this.scannedFiles.add(this.normalize(fileName));
311
324
  this.refreshChecker();
312
325
  this.indexFile(fileName, config);
326
+ this.resolveWrapperChains();
313
327
  return this.extractFile(fileName, config);
314
328
  }
315
329
  async removeFile(fileName, config) {
package/dist/index.js CHANGED
@@ -39,45 +39,53 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
40
  const commander_1 = require("commander");
41
41
  const package_json_1 = __importDefault(require("../package.json"));
42
- const program = new commander_1.Command();
43
- program
44
- .name("eventra")
45
- .description("Eventra CLI")
46
- .version(package_json_1.default.version);
47
- program
48
- .command("init")
49
- .option("-y, --yes", "do not prompt: accept defaults and keep an existing eventra.json")
50
- .action(async (opts) => {
51
- const { init } = await Promise.resolve().then(() => __importStar(require("./cli/init")));
52
- await init({ yes: opts.yes });
42
+ const respawn_1 = require("./respawn");
43
+ // a large repository can need more memory than Node's default: start again with enough
44
+ (0, respawn_1.respawnWithMoreHeap)().then((respawned) => {
45
+ if (!respawned)
46
+ run();
53
47
  });
54
- program
55
- .command("sync")
56
- .option("--strict", "exit with a non-zero code if any file could not be analysed")
57
- .action(async (opts) => {
58
- const { sync } = await Promise.resolve().then(() => __importStar(require("./cli/sync")));
59
- await sync({ strict: opts.strict });
60
- });
61
- program
62
- .command("check")
63
- .option("--fix", "auto fix issues")
64
- .option("--strict", "exit with a non-zero code if any file could not be analysed")
65
- .action(async (opts) => {
66
- const { check } = await Promise.resolve().then(() => __importStar(require("./cli/check")));
67
- await check({ fix: opts.fix, strict: opts.strict });
68
- });
69
- program
70
- .command("watch")
71
- .description("Watch files and detect events in real-time")
72
- .action(async () => {
73
- const { watch } = await Promise.resolve().then(() => __importStar(require("./cli/watch")));
74
- await watch();
75
- });
76
- program
77
- .command("send")
78
- .option("--trust-endpoint", "approve the current eventra.json endpoint for local use")
79
- .action(async (opts) => {
80
- const { send } = await Promise.resolve().then(() => __importStar(require("./cli/send")));
81
- await send({ trustEndpoint: opts.trustEndpoint });
82
- });
83
- program.parse();
48
+ function run() {
49
+ const program = new commander_1.Command();
50
+ program
51
+ .name("eventra")
52
+ .description("Eventra CLI")
53
+ .version(package_json_1.default.version);
54
+ program
55
+ .command("init")
56
+ .option("-y, --yes", "do not prompt: accept defaults and keep an existing eventra.json")
57
+ .action(async (opts) => {
58
+ const { init } = await Promise.resolve().then(() => __importStar(require("./cli/init")));
59
+ await init({ yes: opts.yes });
60
+ });
61
+ program
62
+ .command("sync")
63
+ .option("--strict", "exit with a non-zero code if any file could not be analysed")
64
+ .action(async (opts) => {
65
+ const { sync } = await Promise.resolve().then(() => __importStar(require("./cli/sync")));
66
+ await sync({ strict: opts.strict });
67
+ });
68
+ program
69
+ .command("check")
70
+ .option("--fix", "auto fix issues")
71
+ .option("--strict", "exit with a non-zero code if any file could not be analysed")
72
+ .action(async (opts) => {
73
+ const { check } = await Promise.resolve().then(() => __importStar(require("./cli/check")));
74
+ await check({ fix: opts.fix, strict: opts.strict });
75
+ });
76
+ program
77
+ .command("watch")
78
+ .description("Watch files and detect events in real-time")
79
+ .action(async () => {
80
+ const { watch } = await Promise.resolve().then(() => __importStar(require("./cli/watch")));
81
+ await watch();
82
+ });
83
+ program
84
+ .command("send")
85
+ .option("--trust-endpoint", "approve the current eventra.json endpoint for local use")
86
+ .action(async (opts) => {
87
+ const { send } = await Promise.resolve().then(() => __importStar(require("./cli/send")));
88
+ await send({ trustEndpoint: opts.trustEndpoint });
89
+ });
90
+ program.parse();
91
+ }
@@ -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.0",
4
4
  "description": "Static analytics event extraction CLI powered by the TypeScript compiler API",
5
5
  "license": "MIT",
6
6
  "author": {