@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 +35 -7
- package/dist/analysis/resolver/callResolver.js +16 -2
- package/dist/analysis/scanner/scanner.js +1 -0
- package/dist/analysis/scanner/wrapperChains.js +115 -0
- package/dist/analysis/sdk/eventraSdk.js +6 -4
- package/dist/cli/init.js +45 -10
- package/dist/cli/projectDependencies.js +8 -3
- package/dist/compiler/compilerContext.js +158 -16
- package/dist/compiler/workspacePackages.js +58 -0
- package/dist/config/config.js +16 -1
- package/dist/core/EventraEngine.js +14 -0
- package/dist/index.js +49 -41
- package/dist/plugin/builtins/configuredSinks.js +84 -17
- package/dist/plugin/builtins/consoleDynamicEventReporter.js +3 -0
- package/dist/plugin/registry.js +33 -0
- package/dist/respawn.js +89 -0
- package/package.json +1 -1
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.
|
|
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
|
|
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**
|
|
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, **
|
|
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
|
}
|
|
@@ -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
|
|
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
|
|
17
|
-
* `App: User Sign Up` survive.
|
|
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)(
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/config/config.js
CHANGED
|
@@ -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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
55
|
-
.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/plugin/registry.js
CHANGED
|
@@ -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")
|
package/dist/respawn.js
ADDED
|
@@ -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
|
+
}
|