@eventra_dev/eventra-cli 2.2.0 → 2.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -7
- package/dist/analysis/extractor/extractor.js +2 -1
- package/dist/analysis/resolver/callResolver.js +81 -3
- 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/analysis/sdk/isEventraTrackCall.js +79 -3
- package/dist/analysis/symbols/symbolUtils.js +41 -0
- 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 |
|
|
@@ -8,6 +8,7 @@ const typescript_1 = __importDefault(require("typescript"));
|
|
|
8
8
|
const resolver_1 = require("../resolver/resolver");
|
|
9
9
|
const evaluationContext_1 = require("../shared/evaluationContext");
|
|
10
10
|
const propagationExtractor_1 = require("./propagationExtractor");
|
|
11
|
+
const symbolUtils_1 = require("../symbols/symbolUtils");
|
|
11
12
|
const eventValidation_1 = require("../shared/eventValidation");
|
|
12
13
|
const dynamicOccurrence_1 = require("../shared/dynamicOccurrence");
|
|
13
14
|
function extractEvents(index, checker, _config, evaluationCache, exportCache, resolvedCallCache, returnPropagationCache, wrapperRegistry) {
|
|
@@ -38,7 +39,7 @@ function extractEvents(index, checker, _config, evaluationCache, exportCache, re
|
|
|
38
39
|
visit(index.sourceFile);
|
|
39
40
|
return {
|
|
40
41
|
events,
|
|
41
|
-
detectedFunctionWrappers: new Set(index.wrappers.map(wrapper => wrapper.symbol
|
|
42
|
+
detectedFunctionWrappers: new Set(index.wrappers.map(wrapper => (0, symbolUtils_1.wrapperName)(wrapper.declaration, wrapper.symbol))),
|
|
42
43
|
dynamicOccurrences,
|
|
43
44
|
};
|
|
44
45
|
}
|
|
@@ -9,7 +9,60 @@ 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
|
|
12
|
+
/** `return expr` statements of a function body, not looking into functions nested in it. */
|
|
13
|
+
function returnedExpressions(fn) {
|
|
14
|
+
if (!fn.body) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
if (!typescript_1.default.isBlock(fn.body)) {
|
|
18
|
+
return [fn.body];
|
|
19
|
+
}
|
|
20
|
+
const found = [];
|
|
21
|
+
const visit = (node) => {
|
|
22
|
+
if (typescript_1.default.isFunctionLike(node)) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (typescript_1.default.isReturnStatement(node) && node.expression) {
|
|
26
|
+
found.push(node.expression);
|
|
27
|
+
}
|
|
28
|
+
typescript_1.default.forEachChild(node, visit);
|
|
29
|
+
};
|
|
30
|
+
visit(fn.body);
|
|
31
|
+
return found;
|
|
32
|
+
}
|
|
33
|
+
const MAX_RETURN_HOPS = 4;
|
|
34
|
+
/**
|
|
35
|
+
* The function a call hands back: `useTracker()` for `function useTracker() { return (n) => t.track(n) }`,
|
|
36
|
+
* where the returned value is a function expression or a name bound to one.
|
|
37
|
+
*/
|
|
38
|
+
function resolveReturnedFunction(call, checker, callCache, exportCache, hops) {
|
|
39
|
+
if (hops > MAX_RETURN_HOPS) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
const factory = resolveFunctionFromCall(call.expression, checker, callCache, exportCache, hops + 1);
|
|
43
|
+
if (!factory) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
for (const returned of returnedExpressions(factory)) {
|
|
47
|
+
let value = returned;
|
|
48
|
+
while (typescript_1.default.isParenthesizedExpression(value) || typescript_1.default.isAsExpression(value) || typescript_1.default.isNonNullExpression(value)) {
|
|
49
|
+
value = value.expression;
|
|
50
|
+
}
|
|
51
|
+
if (isFunctionLike(value)) {
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
if (typescript_1.default.isIdentifier(value)) {
|
|
55
|
+
for (const next of checker.getSymbolAtLocation(value)?.getDeclarations() ?? []) {
|
|
56
|
+
const resolved = resolveDeclaration(next, checker);
|
|
57
|
+
if (resolved) {
|
|
58
|
+
return resolved;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function resolveDeclaration(declaration, checker) {
|
|
13
66
|
// function foo() {}
|
|
14
67
|
if (isFunctionLike(declaration)) {
|
|
15
68
|
return declaration;
|
|
@@ -26,6 +79,20 @@ function resolveDeclaration(declaration) {
|
|
|
26
79
|
if (typescript_1.default.isPropertyAssignment(declaration) && declaration.initializer && isFunctionLike(declaration.initializer)) {
|
|
27
80
|
return declaration.initializer;
|
|
28
81
|
}
|
|
82
|
+
// `return { capture }` / `return { capture: capture }`: a hook or factory that hands back functions
|
|
83
|
+
// it defined, so `analytics.capture(...)` is a call of the `capture` declared next to the return
|
|
84
|
+
const target = typescript_1.default.isShorthandPropertyAssignment(declaration)
|
|
85
|
+
? checker.getShorthandAssignmentValueSymbol(declaration)
|
|
86
|
+
: typescript_1.default.isPropertyAssignment(declaration) && typescript_1.default.isIdentifier(declaration.initializer)
|
|
87
|
+
? checker.getSymbolAtLocation(declaration.initializer)
|
|
88
|
+
: undefined;
|
|
89
|
+
// the target is a variable, never another property, so this does not recurse into a cycle
|
|
90
|
+
for (const next of target?.getDeclarations() ?? []) {
|
|
91
|
+
const resolved = resolveDeclaration(next, checker);
|
|
92
|
+
if (resolved) {
|
|
93
|
+
return resolved;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
29
96
|
return null;
|
|
30
97
|
}
|
|
31
98
|
function getExpressionSymbol(expression, checker) {
|
|
@@ -50,7 +117,15 @@ function getExpressionSymbol(expression, checker) {
|
|
|
50
117
|
// track()
|
|
51
118
|
return checker.getSymbolAtLocation(expression);
|
|
52
119
|
}
|
|
53
|
-
function resolveFunctionFromCall(expression, checker, callCache, exportCache) {
|
|
120
|
+
function resolveFunctionFromCall(expression, checker, callCache, exportCache, hops = 0) {
|
|
121
|
+
// useTracker()("x")
|
|
122
|
+
let callee = expression;
|
|
123
|
+
while (typescript_1.default.isParenthesizedExpression(callee)) {
|
|
124
|
+
callee = callee.expression;
|
|
125
|
+
}
|
|
126
|
+
if (typescript_1.default.isCallExpression(callee)) {
|
|
127
|
+
return resolveReturnedFunction(callee, checker, callCache, exportCache, hops);
|
|
128
|
+
}
|
|
54
129
|
let symbol = getExpressionSymbol(expression, checker);
|
|
55
130
|
if (!symbol) {
|
|
56
131
|
return null;
|
|
@@ -69,7 +144,10 @@ function resolveFunctionFromCall(expression, checker, callCache, exportCache) {
|
|
|
69
144
|
// declaration resolution
|
|
70
145
|
const declarations = symbol.getDeclarations() ?? [];
|
|
71
146
|
for (const declaration of declarations) {
|
|
72
|
-
const
|
|
147
|
+
// const track = useTracker(); track("x")
|
|
148
|
+
const resolved = resolveDeclaration(declaration, checker) ?? (typescript_1.default.isVariableDeclaration(declaration) && declaration.initializer && typescript_1.default.isCallExpression(declaration.initializer)
|
|
149
|
+
? resolveReturnedFunction(declaration.initializer, checker, callCache, exportCache, hops)
|
|
150
|
+
: null);
|
|
73
151
|
if (!resolved) {
|
|
74
152
|
continue;
|
|
75
153
|
}
|
|
@@ -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) {
|
|
@@ -91,7 +91,12 @@ function isEventraNewExpression(node, checker) {
|
|
|
91
91
|
return false;
|
|
92
92
|
}
|
|
93
93
|
const symbol = checker.getSymbolAtLocation(expr);
|
|
94
|
-
|
|
94
|
+
if (symbol && isEventraSymbol(symbol, checker)) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
// `const { Eventra } = await import("@eventra_dev/eventra-sdk")`: the binding is not an import, but
|
|
98
|
+
// the instance it builds is still typed as the SDK's class
|
|
99
|
+
return typeLooksLikeEventra(checker.getTypeAtLocation(node), checker);
|
|
95
100
|
}
|
|
96
101
|
function typeLooksLikeEventra(type, checker, depth = 0) {
|
|
97
102
|
// cycle guard - TypeScript flattens union/intersection types (a literal
|
|
@@ -115,7 +120,63 @@ function typeLooksLikeEventra(type, checker, depth = 0) {
|
|
|
115
120
|
}
|
|
116
121
|
return false;
|
|
117
122
|
}
|
|
118
|
-
|
|
123
|
+
const MAX_RECEIVER_HOPS = 6;
|
|
124
|
+
/** `return expr` statements of a function body, not looking into functions nested in it. */
|
|
125
|
+
function returnedExpressions(fn) {
|
|
126
|
+
if (!fn.body) {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
if (!typescript_1.default.isBlock(fn.body)) {
|
|
130
|
+
return [fn.body];
|
|
131
|
+
}
|
|
132
|
+
const found = [];
|
|
133
|
+
const visit = (node) => {
|
|
134
|
+
if (typescript_1.default.isFunctionLike(node)) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (typescript_1.default.isReturnStatement(node) && node.expression) {
|
|
138
|
+
found.push(node.expression);
|
|
139
|
+
}
|
|
140
|
+
typescript_1.default.forEachChild(node, visit);
|
|
141
|
+
};
|
|
142
|
+
visit(fn.body);
|
|
143
|
+
return found;
|
|
144
|
+
}
|
|
145
|
+
function functionOf(decl) {
|
|
146
|
+
if (typescript_1.default.isFunctionDeclaration(decl) || typescript_1.default.isMethodDeclaration(decl)) {
|
|
147
|
+
return decl;
|
|
148
|
+
}
|
|
149
|
+
if (typescript_1.default.isVariableDeclaration(decl) && decl.initializer && (typescript_1.default.isArrowFunction(decl.initializer) || typescript_1.default.isFunctionExpression(decl.initializer))) {
|
|
150
|
+
return decl.initializer;
|
|
151
|
+
}
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
/** `t = new Eventra(...)` anywhere in the file of a variable's declaration: a lazily filled `let t: any`. */
|
|
155
|
+
function assignedValues(symbol, checker) {
|
|
156
|
+
const values = [];
|
|
157
|
+
const files = new Set((symbol.getDeclarations() ?? []).map((decl) => decl.getSourceFile()));
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
const visit = (node) => {
|
|
160
|
+
if (typescript_1.default.isBinaryExpression(node) &&
|
|
161
|
+
node.operatorToken.kind === typescript_1.default.SyntaxKind.EqualsToken &&
|
|
162
|
+
typescript_1.default.isIdentifier(node.left) &&
|
|
163
|
+
checker.getSymbolAtLocation(node.left) === symbol) {
|
|
164
|
+
values.push(node.right);
|
|
165
|
+
}
|
|
166
|
+
typescript_1.default.forEachChild(node, visit);
|
|
167
|
+
};
|
|
168
|
+
visit(file);
|
|
169
|
+
}
|
|
170
|
+
return values;
|
|
171
|
+
}
|
|
172
|
+
function isEventraReceiverExpression(receiver, checker, hops = 0) {
|
|
173
|
+
if (hops > MAX_RECEIVER_HOPS) {
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
// `(await getTracker()).track(...)`, `(tracker as Eventra).track(...)`, `tracker!.track(...)`
|
|
177
|
+
if (typescript_1.default.isParenthesizedExpression(receiver) || typescript_1.default.isAwaitExpression(receiver) || typescript_1.default.isNonNullExpression(receiver) || typescript_1.default.isAsExpression(receiver)) {
|
|
178
|
+
return isEventraReceiverExpression(receiver.expression, checker, hops + 1);
|
|
179
|
+
}
|
|
119
180
|
if (typescript_1.default.isNewExpression(receiver)) {
|
|
120
181
|
return isEventraNewExpression(receiver, checker);
|
|
121
182
|
}
|
|
@@ -126,6 +187,18 @@ function isEventraReceiverExpression(receiver, checker) {
|
|
|
126
187
|
if (typeLooksLikeEventra(type, checker)) {
|
|
127
188
|
return true;
|
|
128
189
|
}
|
|
190
|
+
// `getTracker().track(...)` where the getter's type is lost (`any`) but what it returns is a tracker
|
|
191
|
+
if (typescript_1.default.isCallExpression(receiver)) {
|
|
192
|
+
const callee = typescript_1.default.isIdentifier(receiver.expression) || typescript_1.default.isPropertyAccessExpression(receiver.expression)
|
|
193
|
+
? checker.getSymbolAtLocation(typescript_1.default.isIdentifier(receiver.expression) ? receiver.expression : receiver.expression.name)
|
|
194
|
+
: undefined;
|
|
195
|
+
for (const decl of callee?.getDeclarations() ?? []) {
|
|
196
|
+
const fn = functionOf(decl);
|
|
197
|
+
if (fn && returnedExpressions(fn).some((returned) => isEventraReceiverExpression(returned, checker, hops + 1))) {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
129
202
|
if (typescript_1.default.isIdentifier(receiver)) {
|
|
130
203
|
const symbol = checker.getSymbolAtLocation(receiver);
|
|
131
204
|
if (!symbol) {
|
|
@@ -134,6 +207,9 @@ function isEventraReceiverExpression(receiver, checker) {
|
|
|
134
207
|
// See the identical `?? []` above isEventraSymbol's declaration loop: a
|
|
135
208
|
// real ts.Symbol here always has a declarations array.
|
|
136
209
|
/* v8 ignore next */
|
|
210
|
+
if (assignedValues(symbol, checker).some((value) => isEventraReceiverExpression(value, checker, hops + 1))) {
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
137
213
|
for (const decl of symbol.getDeclarations() ?? []) {
|
|
138
214
|
if (typescript_1.default.isVariableDeclaration(decl) && decl.initializer) {
|
|
139
215
|
if (typescript_1.default.isNewExpression(decl.initializer) && isEventraNewExpression(decl.initializer, checker)) {
|
|
@@ -167,7 +243,7 @@ function isEventraReceiverExpression(receiver, checker) {
|
|
|
167
243
|
}
|
|
168
244
|
}
|
|
169
245
|
if (typescript_1.default.isPropertyAccessExpression(receiver) || typescript_1.default.isPropertyAccessChain(receiver)) {
|
|
170
|
-
return isEventraReceiverExpression(receiver.expression, checker);
|
|
246
|
+
return isEventraReceiverExpression(receiver.expression, checker, hops + 1);
|
|
171
247
|
}
|
|
172
248
|
return false;
|
|
173
249
|
}
|
|
@@ -5,6 +5,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.getValueSymbol = getValueSymbol;
|
|
7
7
|
exports.getFunctionSymbol = getFunctionSymbol;
|
|
8
|
+
exports.isReturnedFunction = isReturnedFunction;
|
|
9
|
+
exports.wrapperName = wrapperName;
|
|
8
10
|
const typescript_1 = __importDefault(require("typescript"));
|
|
9
11
|
/**
|
|
10
12
|
* Symbol an identifier *refers to*. For the name in a shorthand property (`{ event }`) a plain
|
|
@@ -53,5 +55,44 @@ function getFunctionSymbol(node, checker) {
|
|
|
53
55
|
if (typescript_1.default.isExportAssignment(parent)) {
|
|
54
56
|
return checker.getSymbolAtLocation(node);
|
|
55
57
|
}
|
|
58
|
+
// `return (name) => ...` / `() => (name) => ...`: a function a hook or factory hands back has no
|
|
59
|
+
// name of its own, but the binder still gives it a symbol, and that is what a call of the result
|
|
60
|
+
// (`const track = useTracker(); track("x")`) is matched against.
|
|
61
|
+
if (isReturnedFunction(node)) {
|
|
62
|
+
return node.symbol;
|
|
63
|
+
}
|
|
56
64
|
return undefined;
|
|
57
65
|
}
|
|
66
|
+
/** An arrow/function expression that is the value of a `return`, or the concise body of an arrow. */
|
|
67
|
+
function isReturnedFunction(node) {
|
|
68
|
+
if (!typescript_1.default.isArrowFunction(node) && !typescript_1.default.isFunctionExpression(node)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
let outer = node.parent;
|
|
72
|
+
while (typescript_1.default.isParenthesizedExpression(outer)) {
|
|
73
|
+
outer = outer.parent;
|
|
74
|
+
}
|
|
75
|
+
return typescript_1.default.isReturnStatement(outer) || (typescript_1.default.isArrowFunction(outer) && unparen(outer.body) === node);
|
|
76
|
+
}
|
|
77
|
+
function unparen(node) {
|
|
78
|
+
while (typescript_1.default.isParenthesizedExpression(node)) {
|
|
79
|
+
node = node.expression;
|
|
80
|
+
}
|
|
81
|
+
return node;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The name a wrapper is reported under: its own, or for an anonymous returned function the hook that
|
|
85
|
+
* returns it (`useTracker` for `function useTracker() { return (n) => t.track(n); }`).
|
|
86
|
+
*/
|
|
87
|
+
function wrapperName(declaration, symbol) {
|
|
88
|
+
if (!isReturnedFunction(declaration)) {
|
|
89
|
+
return symbol.getName();
|
|
90
|
+
}
|
|
91
|
+
const owner = typescript_1.default.findAncestor(declaration.parent, (n) => typescript_1.default.isFunctionLike(n));
|
|
92
|
+
const ownerName = owner && owner.name;
|
|
93
|
+
if (ownerName && typescript_1.default.isIdentifier(ownerName)) {
|
|
94
|
+
return ownerName.text;
|
|
95
|
+
}
|
|
96
|
+
const holder = owner?.parent;
|
|
97
|
+
return holder && typescript_1.default.isVariableDeclaration(holder) && typescript_1.default.isIdentifier(holder.name) ? holder.name.text : symbol.getName();
|
|
98
|
+
}
|