@eventra_dev/eventra-cli 2.0.9 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +14 -2
- package/README.md +83 -10
- package/dist/analysis/extractor/extractor.js +1 -1
- package/dist/analysis/extractor/propagationExtractor.js +56 -8
- package/dist/analysis/resolver/resolver.js +7 -4
- package/dist/analysis/scanner/analyzer/propagationAnalyzer.js +5 -0
- package/dist/analysis/scanner/scanner.js +10 -3
- package/dist/analysis/sdk/eventraSdk.js +6 -1
- package/dist/analysis/symbols/symbolUtils.js +12 -0
- package/dist/cli/init.js +30 -0
- package/dist/cli/projectDependencies.js +119 -0
- package/dist/config/config.js +7 -0
- package/dist/plugin/builtins/configuredSinks.js +253 -0
- package/dist/plugin/builtins/sinkPresets.js +99 -0
- package/dist/plugin/loadPlugins.js +2 -0
- package/package.json +10 -2
package/LICENSE
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2026
|
|
3
|
+
Copyright (c) 2026 and-1991
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
|
7
7
|
in the Software without restriction, including without limitation the rights
|
|
8
8
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<a href="https://www.npmjs.com/package/@eventra_dev/eventra-cli"><img alt="npm version" src="https://img.shields.io/npm/v/@eventra_dev/eventra-cli.svg?style=flat-square&color=blue"></a>
|
|
9
9
|
<a href="https://www.npmjs.com/package/@eventra_dev/eventra-cli"><img alt="npm downloads" src="https://img.shields.io/npm/dm/@eventra_dev/eventra-cli.svg?style=flat-square&color=blue"></a>
|
|
10
10
|
<a href="https://github.com/and-1991/eventra-cli/actions/workflows/test.yml"><img alt="tests" src="https://img.shields.io/github/actions/workflow/status/and-1991/eventra-cli/test.yml?branch=main&label=tests&style=flat-square&logo=vitest&logoColor=white"></a>
|
|
11
|
-
<img alt="unit tests" src="https://img.shields.io/badge/unit-
|
|
12
|
-
<img alt="e2e fixtures" src="https://img.shields.io/badge/e2e-
|
|
11
|
+
<img alt="unit tests" src="https://img.shields.io/badge/unit-665%20passing-brightgreen?style=flat-square&logo=vitest&logoColor=white">
|
|
12
|
+
<img alt="e2e fixtures" src="https://img.shields.io/badge/e2e-14%20fixtures-brightgreen?style=flat-square">
|
|
13
13
|
<img alt="coverage" src="https://img.shields.io/badge/coverage-100%25%20(unit)-brightgreen?style=flat-square&logo=vitest&logoColor=white">
|
|
14
14
|
<img alt="node" src="https://img.shields.io/node/v/@eventra_dev/eventra-cli?style=flat-square&color=darkgreen&logo=node.js&logoColor=white">
|
|
15
15
|
<a href="https://www.typescriptlang.org/"><img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-ready-blue?style=flat-square&logo=typescript&logoColor=white"></a>
|
|
@@ -24,7 +24,7 @@ Eventra CLI statically discovers analytics events from [**@eventra_dev/eventra-s
|
|
|
24
24
|
|
|
25
25
|
The CLI scans TypeScript and JavaScript with the TypeScript compiler API and extracts **only** calls to `Eventra.prototype.track()` on instances of `Eventra` imported from `@eventra_dev/eventra-sdk`.
|
|
26
26
|
|
|
27
|
-
It does **not** detect generic `track()`, Segment, Google Analytics, or other libraries out of the box. Framework files (e.g. `.vue`, `.svelte`, `.astro`, Angular's `.html` templates) require an optional [plugin](#plugins).
|
|
27
|
+
It does **not** detect generic `track()`, Segment, Google Analytics, or other libraries out of the box - but you can opt in to a third-party library with [`sinks`](#third-party-analytics-sinks) (PostHog, Segment, Amplitude and Google Analytics 4 have built-in presets). Framework files (e.g. `.vue`, `.svelte`, `.astro`, Angular's `.html` templates) require an optional [plugin](#plugins).
|
|
28
28
|
|
|
29
29
|
---
|
|
30
30
|
|
|
@@ -121,12 +121,51 @@ trackFeature("purchase");
|
|
|
121
121
|
|
|
122
122
|
---
|
|
123
123
|
|
|
124
|
+
## Third-party analytics (`sinks`)
|
|
125
|
+
|
|
126
|
+
Already have analytics in place? Declare it in `eventra.json` and the CLI treats its calls as event sources too, with no SDK swap. Wrappers, constants, cross-file imports and JSX component props resolve exactly as they do for the Eventra SDK.
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"sinks": [
|
|
131
|
+
{ "preset": "posthog" },
|
|
132
|
+
{ "preset": "segment" },
|
|
133
|
+
{ "preset": "amplitude" },
|
|
134
|
+
{ "preset": "ga4" },
|
|
135
|
+
{ "callee": "analytics.track" },
|
|
136
|
+
{ "callee": "gtag", "nameArg": 1, "when": { "arg": 0, "equals": "event" } }
|
|
137
|
+
]
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
| Field | Meaning |
|
|
142
|
+
|-------|---------|
|
|
143
|
+
| `preset` | A built-in rule set: `posthog`, `segment`, `amplitude` or `ga4` (see below) |
|
|
144
|
+
| `callee` | Dotted call path, matched as a suffix: `analytics.track` also matches `this.analytics.track`, but not `myanalytics.track` |
|
|
145
|
+
| `nameArg` | Zero-based index of the event-name argument (default `0`) |
|
|
146
|
+
| `nameProperty` | If that argument is an object literal, read the name from this property (e.g. `event`) |
|
|
147
|
+
| `modules` | Only match when the receiver comes from one of these npm packages (imported, re-exported, typed, or built from them) |
|
|
148
|
+
| `when` | Only match when the argument at `arg` is the string literal `equals`, e.g. `{ "arg": 0, "equals": "event" }` for `gtag("event", name)` |
|
|
149
|
+
|
|
150
|
+
| Preset | Detects |
|
|
151
|
+
|--------|---------|
|
|
152
|
+
| `posthog` | `posthog.capture("x")`, the `usePostHog()` hook (from `posthog-js/react` or `@posthog/react`) under any variable name (also through re-exports like `export { usePostHog as useAnalytics }`), and posthog-node's `client.capture({ distinctId, event: "x" })`, including typed class fields |
|
|
153
|
+
| `segment` | `analytics.track("x", props)` (analytics.js, `window.analytics`), analytics-node's `client.track({ userId, event: "x" })`, and clients from `@segment/analytics-next`, `@segment/analytics-node`, `@segment/analytics-react-native` or `analytics-node` under any variable name, including `const [analytics] = await AnalyticsBrowser.load(...)` |
|
|
154
|
+
| `amplitude` | `amplitude.track("x")`, `amplitude.track({ event_type: "x" })`, `amplitude.logEvent("x")`, `amplitude.getInstance().logEvent("x")`, and clients from the `@amplitude/*` browser, node, react-native and unified packages or `amplitude-js` |
|
|
155
|
+
| `ga4` | `gtag("event", "x", params)` (also `window.gtag` and nuxt-gtag's `const { gtag } = useGtag()`), `sendGAEvent("event", "x", params)` from `@next/third-parties`, nuxt-gtag's `useTrackEvent("x")`, vue-gtag's `this.$gtag.event("x")` and `event("x")` / `const { event } = useGtag()`, and react-ga4's `ReactGA.event("x")` / `ReactGA.event({ category, action: "x" })`. Other gtag commands (`gtag("config", id)`, `gtag("set", ...)`) are not events and are ignored |
|
|
156
|
+
|
|
157
|
+
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
|
+
|
|
159
|
+
The [name rules](#event-name-rules-aligned-with-sdk) below apply to these events too. Spaces and colons are allowed, so names like `App: User Sign Up` or `Order Completed` are kept as written. Names with other characters, such as PostHog's `$`-prefixed system events (`$pageview`) or a name ending in `!`, are excluded from the scan result.
|
|
160
|
+
|
|
161
|
+
---
|
|
162
|
+
|
|
124
163
|
## Event name rules (aligned with SDK)
|
|
125
164
|
|
|
126
165
|
| Rule | Limit |
|
|
127
166
|
|------|--------|
|
|
128
167
|
| Max length | **64 characters** (same as SDK) |
|
|
129
|
-
| Allowed characters | `a-zA-Z0-9:_./-` |
|
|
168
|
+
| Allowed characters | `a-zA-Z0-9:_./-` and the space (so `App: User Sign Up` works); tabs, newlines and any other character are rejected |
|
|
130
169
|
| Position | First argument of `.track(name, options?)` |
|
|
131
170
|
|
|
132
171
|
The second argument (`userId`, `properties`) is **not** used as the event name.
|
|
@@ -137,8 +176,10 @@ A literal name that violates either rule (too long, or a character outside the a
|
|
|
137
176
|
|
|
138
177
|
## What is ignored
|
|
139
178
|
|
|
179
|
+
By default only the Eventra SDK is read. Calls to other analytics are ignored unless you enable them with [`sinks`](#third-party-analytics-sinks).
|
|
180
|
+
|
|
140
181
|
```ts
|
|
141
|
-
// Not from @eventra_dev/eventra-sdk
|
|
182
|
+
// Not from @eventra_dev/eventra-sdk (and no matching `sinks` rule)
|
|
142
183
|
track("legacy");
|
|
143
184
|
analytics.track("ga");
|
|
144
185
|
segment.track("x");
|
|
@@ -151,6 +192,28 @@ tracker.track({ event: "click" });
|
|
|
151
192
|
|
|
152
193
|
## Commands
|
|
153
194
|
|
|
195
|
+
### `eventra init`
|
|
196
|
+
|
|
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:
|
|
198
|
+
|
|
199
|
+
```
|
|
200
|
+
Found analytics in package.json: posthog-js; @segment/analytics-next
|
|
201
|
+
? Also read events from these analytics calls (sinks):
|
|
202
|
+
❯◉ posthog (posthog-js)
|
|
203
|
+
◉ segment (@segment/analytics-next)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Everything found is pre-selected; press Enter to accept or untick what you do not want. The chosen presets are written to `sinks` in `eventra.json`. Recognised packages:
|
|
207
|
+
|
|
208
|
+
| Preset | Packages |
|
|
209
|
+
|--------|----------|
|
|
210
|
+
| `posthog` | `posthog-js`, `@posthog/react`, `posthog-js-lite`, `posthog-node`, `posthog-react-native` |
|
|
211
|
+
| `segment` | `@segment/analytics-next`, `@segment/analytics-node`, `@segment/analytics-react-native`, `@segment/snippet`, and the older `analytics-node`, `@segment/analytics.js-core` |
|
|
212
|
+
| `amplitude` | `@amplitude/analytics-browser`, `@amplitude/analytics-node`, `@amplitude/analytics-react-native`, `@amplitude/unified`, `@amplitude/marketing-analytics-browser`, and the older `amplitude-js` |
|
|
213
|
+
| `ga4` | `react-ga4`, `vue-gtag`, `@next/third-parties`, `nuxt-gtag`, `@types/gtag.js`, and the older `vue-gtag-next` |
|
|
214
|
+
|
|
215
|
+
Detection reads dependencies only. A tag pasted straight into the HTML (a Segment or `gtag.js` snippet with no package) is not seen: add the preset to `sinks` yourself. In a monorepo only the block-list form of `packages:` in `pnpm-workspace.yaml` is understood (not `packages: [a, b]`), and workspace patterns that are absolute or contain `..` are ignored. Running `init` again rewrites `eventra.json`.
|
|
216
|
+
|
|
154
217
|
### `eventra sync`
|
|
155
218
|
|
|
156
219
|
Full project scan → updates `eventra.json`:
|
|
@@ -338,6 +401,7 @@ No dependency on `@eventra_dev/eventra-cli` is required — the host CLI loads t
|
|
|
338
401
|
"events": [],
|
|
339
402
|
"functionWrappers": [],
|
|
340
403
|
"plugins": [],
|
|
404
|
+
"sinks": [],
|
|
341
405
|
"sync": {
|
|
342
406
|
"include": ["**/*.{ts,tsx,js,jsx}"],
|
|
343
407
|
"exclude": ["node_modules", "dist", ".next", ".git"]
|
|
@@ -350,6 +414,7 @@ No dependency on `@eventra_dev/eventra-cli` is required — the host CLI loads t
|
|
|
350
414
|
| `apiKey` | Legacy inline API key — leave empty. Use `EVENTRA_API_KEY` or `eventra.local.json` instead (see [`eventra send`](#eventra-send)) so a real key never ends up in this committed file |
|
|
351
415
|
| `endpoint` | Custom `eventra send` target (e.g. self-hosted). Non-default values from this file need one-time local approval — see [`eventra send`](#eventra-send) |
|
|
352
416
|
| `plugins` | `@eventra_dev/cli-plugin-*` specifiers to `import()` at startup. Anything outside that scope is skipped (see [Plugin contract](#plugin-contract-for-authors)) |
|
|
417
|
+
| `sinks` | Third-party analytics calls to treat as event sources, e.g. `[{ "preset": "posthog" }]` (see [Third-party analytics](#third-party-analytics-sinks)) |
|
|
353
418
|
| `sync.include` | Base glob patterns; plugin `includeGlobs` are merged automatically |
|
|
354
419
|
| `sync.exclude` | Paths skipped during scan |
|
|
355
420
|
|
|
@@ -382,11 +447,11 @@ No runtime execution. No monkey-patching.
|
|
|
382
447
|
|
|
383
448
|
## Test Coverage
|
|
384
449
|
|
|
385
|
-
Two test layers, **
|
|
450
|
+
Two test layers, **665 unit tests + 14 e2e fixtures + 3 `check` exit-code scenarios + 1 `watch` scenario**.
|
|
386
451
|
|
|
387
452
|
**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.
|
|
388
453
|
|
|
389
|
-
**Unit tests (vitest)**
|
|
454
|
+
**Unit tests (vitest)** - 47 suites covering core modules:
|
|
390
455
|
|
|
391
456
|
| Module | Covers |
|
|
392
457
|
|---|---|
|
|
@@ -407,12 +472,18 @@ Two test layers, **445 unit tests + 12 e2e fixtures + 3 `check` exit-code scenar
|
|
|
407
472
|
| `cross-file SDK detection` | Direct `.track()` and wrapper calls resolve across files even when the call-site file never imports the SDK package itself |
|
|
408
473
|
| `dynamic event reporter` | Dynamic vs. static call classification, end-to-end `registerDynamicEventReporter` invocation |
|
|
409
474
|
| `load plugins` | Trusted-specifier allowlist (`^@eventra_dev/cli-plugin-[a-z0-9-]+$`), accepted/rejected plugin shapes |
|
|
410
|
-
| `Nuxt auto-import compatibility` | Ambient `declare const x: typeof import(...)` shape resolution
|
|
475
|
+
| `Nuxt auto-import compatibility` | Ambient `declare const x: typeof import(...)` shape resolution - a hand-written fixture; a real `nuxt prepare` project was also verified manually, but that check is not automated |
|
|
411
476
|
| `property propagation` | Direct/optional/nested property access, destructured/aliased/nested-destructured params, element access, enum member access (same-file and cross-file), wrapper detection surviving a cast/non-null assertion, and direct object-literal payloads to `track()` staying ignored (including when cast) |
|
|
412
477
|
| `send()` | Endpoint trust-on-first-use (TOFU): default-trusted, blocked-until-approved, approve-then-persists, re-block-on-change, env-var-bypasses-approval |
|
|
413
478
|
| `wrapper detector plugin` | A custom `WrapperDetector` (e.g. a template-literal wrapper shape the built-in analyzer can't cover) alongside the built-in fallback |
|
|
479
|
+
| `configured sinks` | `sinks` rules and the PostHog, Segment, Amplitude and GA4 presets: every call shape, receivers traced through imports, re-exports, hooks, typed fields and aliases, the `when` guard, spaced names, wrong-typed config, circular chains |
|
|
480
|
+
| `sink presets` | The package table behind the presets: detection per package, order, legacy and detect-only flags, rules vetted only against their own preset's packages |
|
|
481
|
+
| `project dependencies` | package.json and workspace reading for `eventra init` (`workspaces`, pnpm-workspace.yaml, negations, caps, patterns that leave the project, unreadable files) |
|
|
482
|
+
| `init` | `eventra init` prompts, pre-selected presets, written config, exact console output, API key kept out of `eventra.json` |
|
|
483
|
+
| `preset package check` | The registry verdicts behind `pnpm check:presets` (missing, deprecated, stale, legacy) |
|
|
484
|
+
| `license` | `LICENSE` is the complete MIT text, its holder is the package author, and both match `package.json` |
|
|
414
485
|
|
|
415
|
-
**End-to-end fixtures**
|
|
486
|
+
**End-to-end fixtures** - 14 isolated TS projects scanned via `eventra sync`:
|
|
416
487
|
|
|
417
488
|
| Fixture | Covers |
|
|
418
489
|
|---|---|
|
|
@@ -423,6 +494,8 @@ Two test layers, **445 unit tests + 12 e2e fixtures + 3 `check` exit-code scenar
|
|
|
423
494
|
| `wrappers/barrel` | `export * from "./tracker"` re-exports |
|
|
424
495
|
| `wrappers/default-export` | `export default function trackFeature` propagation |
|
|
425
496
|
| `wrappers/path-aliases` | `tsconfig.json` `paths` mapping (`@app/*`) |
|
|
497
|
+
| `sinks/presets` | PostHog, Segment, Amplitude and GA4 side by side, including `gtag("config")` that must not count |
|
|
498
|
+
| `sinks/spaced-names` | Names with spaces and colons through the SDK, PostHog (client and server shapes), a custom rule, wrappers and JSX, plus calls that must be ignored |
|
|
426
499
|
| `watch-incremental` | Engine state across sequential file updates (matches `sync` output) |
|
|
427
500
|
|
|
428
501
|
**`eventra check` exit-code scenarios:**
|
|
@@ -447,4 +520,4 @@ pnpm --filter @eventra_dev/eventra-cli test:e2e # fixtures + check exit codes
|
|
|
447
520
|
|
|
448
521
|
## License
|
|
449
522
|
|
|
450
|
-
MIT
|
|
523
|
+
MIT, copyright (c) 2026 [and-1991](https://github.com/and-1991). See [LICENSE](LICENSE).
|
|
@@ -30,7 +30,7 @@ function extractEvents(index, checker, _config, evaluationCache, exportCache, re
|
|
|
30
30
|
}
|
|
31
31
|
const propagationVisited = new Set();
|
|
32
32
|
function visit(node) {
|
|
33
|
-
if (typescript_1.default.isCallExpression(node)) {
|
|
33
|
+
if (typescript_1.default.isCallExpression(node) || typescript_1.default.isJsxOpeningElement(node) || typescript_1.default.isJsxSelfClosingElement(node)) {
|
|
34
34
|
(0, propagationExtractor_1.extractPropagationEvents)(node, checker, wrapperRegistry, resolvedCallCache, returnPropagationCache, evaluationCache, exportCache, events, propagationVisited, dynamicOccurrences);
|
|
35
35
|
}
|
|
36
36
|
typescript_1.default.forEachChild(node, visit);
|
|
@@ -8,6 +8,7 @@ const typescript_1 = __importDefault(require("typescript"));
|
|
|
8
8
|
const callResolver_1 = require("../resolver/callResolver");
|
|
9
9
|
const resolver_1 = require("../resolver/resolver");
|
|
10
10
|
const evaluationContext_1 = require("../shared/evaluationContext");
|
|
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 resolveObjectPath(expression, path) {
|
|
@@ -35,8 +36,39 @@ function resolveObjectPath(expression, path) {
|
|
|
35
36
|
}
|
|
36
37
|
return current;
|
|
37
38
|
}
|
|
39
|
+
/**
|
|
40
|
+
* A JSX element is a call of its component with one argument: the props object. Rebuilt here as an
|
|
41
|
+
* object literal from the attributes so the existing call-site machinery (`resolveObjectPath`,
|
|
42
|
+
* parameter bindings) works unchanged. Spread attributes and children are ignored, and each
|
|
43
|
+
* attribute value is the original AST node, so symbol resolution still sees real code.
|
|
44
|
+
*/
|
|
45
|
+
function jsxPropsAsObjectLiteral(element) {
|
|
46
|
+
const properties = [];
|
|
47
|
+
for (const attribute of element.attributes.properties) {
|
|
48
|
+
if (!typescript_1.default.isJsxAttribute(attribute) || !typescript_1.default.isIdentifier(attribute.name) || !attribute.initializer) {
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
const value = typescript_1.default.isJsxExpression(attribute.initializer) ? attribute.initializer.expression : attribute.initializer;
|
|
52
|
+
if (value && !typescript_1.default.isJsxElement(value) && !typescript_1.default.isJsxSelfClosingElement(value) && !typescript_1.default.isJsxFragment(value)) {
|
|
53
|
+
properties.push(typescript_1.default.factory.createPropertyAssignment(attribute.name, value));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return typescript_1.default.factory.createObjectLiteralExpression(properties);
|
|
57
|
+
}
|
|
58
|
+
/** The expression naming the called function/component; null for `<ns:tag>`, which is never a component. */
|
|
59
|
+
function callSiteExpression(site) {
|
|
60
|
+
if (typescript_1.default.isCallExpression(site)) {
|
|
61
|
+
return site.expression;
|
|
62
|
+
}
|
|
63
|
+
return typescript_1.default.isJsxNamespacedName(site.tagName) ? null : site.tagName;
|
|
64
|
+
}
|
|
65
|
+
function callSiteArgument(site, index) {
|
|
66
|
+
// A JSX site only reaches here for index 0 (the props object): see the guard in extractPropagationEvents.
|
|
67
|
+
return typescript_1.default.isCallExpression(site) ? site.arguments[index] : jsxPropsAsObjectLiteral(site);
|
|
68
|
+
}
|
|
38
69
|
function extractPropagationEvents(call, checker, wrapperRegistry, resolvedCallCache, returnPropagationCache, evaluationCache, exportCache, events, visited, dynamicOccurrences) {
|
|
39
|
-
const
|
|
70
|
+
const callee = callSiteExpression(call);
|
|
71
|
+
const resolved = callee && (0, callResolver_1.resolveFunctionFromCall)(callee, checker, resolvedCallCache, exportCache);
|
|
40
72
|
if (!resolved) {
|
|
41
73
|
return;
|
|
42
74
|
}
|
|
@@ -60,6 +92,11 @@ function extractPropagationEvents(call, checker, wrapperRegistry, resolvedCallCa
|
|
|
60
92
|
visited.add(signature);
|
|
61
93
|
try {
|
|
62
94
|
for (const propagation of semantic.propagations) {
|
|
95
|
+
// A component only ever receives props (an object), so a propagation that reads the
|
|
96
|
+
// parameter as a whole (`name` rather than `props.name`) can't be satisfied by a JSX site.
|
|
97
|
+
if (!typescript_1.default.isCallExpression(call) && (propagation.sourceParameterIndex !== 0 || propagation.propertyPath.length === 0)) {
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
63
100
|
extractPropagation(propagation, call, checker, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache, events, dynamicOccurrences);
|
|
64
101
|
}
|
|
65
102
|
}
|
|
@@ -71,11 +108,12 @@ function extractPropagation(propagation, call, checker, evaluationCache, resolve
|
|
|
71
108
|
// Call site can omit the argument and rely on the parameter's own default
|
|
72
109
|
// value (`function f(event = "x") {}` called as `f()`) - that default is a
|
|
73
110
|
// real static literal, so fall back to it instead of dropping the call site.
|
|
74
|
-
const argument = call
|
|
111
|
+
const argument = callSiteArgument(call, propagation.sourceParameterIndex) ?? propagation.sourceParameter.initializer;
|
|
75
112
|
if (!argument) {
|
|
76
113
|
return;
|
|
77
114
|
}
|
|
78
115
|
const context = (0, evaluationContext_1.createEvaluationContext)();
|
|
116
|
+
let directTarget;
|
|
79
117
|
if (typescript_1.default.isIdentifier(propagation.sourceParameter.name)) {
|
|
80
118
|
// sourceParameter is a plain identifier (`function f(payload)`), so
|
|
81
119
|
// propagation.targetNode — `payload`, `payload.event`, or any expression
|
|
@@ -98,20 +136,30 @@ function extractPropagation(propagation, call, checker, evaluationCache, resolve
|
|
|
98
136
|
// returns nothing. Narrow to the leaf value ourselves via propertyPath,
|
|
99
137
|
// then bind it to the destructured binding's own symbol (targetNode is
|
|
100
138
|
// that binding's reference, e.g. `event` in `sdk.track(event)`).
|
|
101
|
-
if (propagation.propertyPath.length === 0
|
|
139
|
+
if (propagation.propertyPath.length === 0) {
|
|
102
140
|
return;
|
|
103
141
|
}
|
|
104
142
|
const resolvedPath = resolveObjectPath(argument, propagation.propertyPath);
|
|
105
143
|
if (!resolvedPath) {
|
|
106
144
|
return;
|
|
107
145
|
}
|
|
108
|
-
|
|
109
|
-
|
|
146
|
+
if (typescript_1.default.isIdentifier(propagation.targetNode)) {
|
|
147
|
+
const bindingSymbol = (0, symbolUtils_1.getValueSymbol)(propagation.targetNode, checker);
|
|
148
|
+
if (!bindingSymbol) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
context.parameterBindings.set(bindingSymbol, resolvedPath);
|
|
152
|
+
}
|
|
153
|
+
else if (typescript_1.default.isPropertyAccessExpression(propagation.targetNode) || typescript_1.default.isElementAccessExpression(propagation.targetNode)) {
|
|
154
|
+
// `function f({ analytics }) { track(analytics.name) }`: propertyPath already walks the binding
|
|
155
|
+
// AND the trailing property access (["analytics", "name"]), so resolvedPath is the leaf value.
|
|
156
|
+
directTarget = resolvedPath;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
110
159
|
return;
|
|
111
160
|
}
|
|
112
|
-
context.parameterBindings.set(bindingSymbol, resolvedPath);
|
|
113
161
|
}
|
|
114
|
-
const resolved = (0, resolver_1.resolveNodeValue)(propagation.targetNode, checker, context, new Set(), evaluationCache, resolvedCallCache, returnPropagationCache, exportCache);
|
|
162
|
+
const resolved = (0, resolver_1.resolveNodeValue)(directTarget ?? propagation.targetNode, checker, context, new Set(), evaluationCache, resolvedCallCache, returnPropagationCache, exportCache);
|
|
115
163
|
for (const value of resolved.values) {
|
|
116
164
|
const normalized = (0, eventValidation_1.normalizeEventName)(value);
|
|
117
165
|
if (normalized) {
|
|
@@ -119,6 +167,6 @@ function extractPropagation(propagation, call, checker, evaluationCache, resolve
|
|
|
119
167
|
}
|
|
120
168
|
}
|
|
121
169
|
if (resolved.dynamic) {
|
|
122
|
-
(0, dynamicOccurrence_1.recordDynamicOccurrence)(call, call.
|
|
170
|
+
(0, dynamicOccurrence_1.recordDynamicOccurrence)(call, callSiteExpression(call).getText(), resolved.values, dynamicOccurrences);
|
|
123
171
|
}
|
|
124
172
|
}
|
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.resolveNodeValue = resolveNodeValue;
|
|
7
7
|
const typescript_1 = __importDefault(require("typescript"));
|
|
8
|
+
const symbolUtils_1 = require("../symbols/symbolUtils");
|
|
8
9
|
const exportResolver_1 = require("./exportResolver");
|
|
9
10
|
const returnPropagationAnalyzer_1 = require("../scanner/analyzer/returnPropagationAnalyzer");
|
|
10
11
|
const callResolver_1 = require("./callResolver");
|
|
@@ -37,7 +38,7 @@ function concat(left, right) {
|
|
|
37
38
|
function resolveBoundObjectNode(rawNode, checker, context) {
|
|
38
39
|
const node = (0, utils_1.unwrapExpression)(rawNode);
|
|
39
40
|
if (typescript_1.default.isIdentifier(node)) {
|
|
40
|
-
const symbol =
|
|
41
|
+
const symbol = (0, symbolUtils_1.getValueSymbol)(node, checker);
|
|
41
42
|
if (!symbol || !context.parameterBindings.has(symbol)) {
|
|
42
43
|
return null;
|
|
43
44
|
}
|
|
@@ -220,10 +221,12 @@ function resolvePropertyAccess(node, checker, context, visited, evaluationCache,
|
|
|
220
221
|
/* v8 ignore next */
|
|
221
222
|
const declarations = symbol.getDeclarations() ?? [];
|
|
222
223
|
for (const declaration of declarations) {
|
|
223
|
-
|
|
224
|
+
// `{ ... } as const` / `satisfies Record<...>` is still the same object literal.
|
|
225
|
+
const objectLiteral = typescript_1.default.isVariableDeclaration(declaration) && declaration.initializer ? (0, utils_1.unwrapExpression)(declaration.initializer) : null;
|
|
226
|
+
if (!objectLiteral || !typescript_1.default.isObjectLiteralExpression(objectLiteral)) {
|
|
224
227
|
continue;
|
|
225
228
|
}
|
|
226
|
-
for (const property of
|
|
229
|
+
for (const property of objectLiteral.properties) {
|
|
227
230
|
if (!typescript_1.default.isPropertyAssignment(property) && !typescript_1.default.isShorthandPropertyAssignment(property)) {
|
|
228
231
|
continue;
|
|
229
232
|
}
|
|
@@ -297,7 +300,7 @@ function collectReassignments(symbol, checker) {
|
|
|
297
300
|
return { values, hasUnresolvedReassignment };
|
|
298
301
|
}
|
|
299
302
|
function resolveIdentifier(node, checker, context, visited, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache) {
|
|
300
|
-
const boundSymbol =
|
|
303
|
+
const boundSymbol = (0, symbolUtils_1.getValueSymbol)(node, checker);
|
|
301
304
|
if (boundSymbol && context.parameterBindings.has(boundSymbol)) {
|
|
302
305
|
const bound = context.parameterBindings.get(boundSymbol);
|
|
303
306
|
// `has(boundSymbol)` above already guarantees a Map entry, and every
|
|
@@ -138,6 +138,11 @@ function collectPropagations(fn, sinks) {
|
|
|
138
138
|
if (!typescript_1.default.isPropertyAssignment(property) && !typescript_1.default.isShorthandPropertyAssignment(property)) {
|
|
139
139
|
continue;
|
|
140
140
|
}
|
|
141
|
+
// The sink only reads one property (e.g. `event`) as the name; `{ distinctId, event }`
|
|
142
|
+
// must not propagate `distinctId` as an event name too.
|
|
143
|
+
if (tracked.propertyPath.length > 0 && property.name.getText() !== tracked.propertyPath[0]) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
141
146
|
const initializer = typescript_1.default.isShorthandPropertyAssignment(property) ? property.name : property.initializer;
|
|
142
147
|
if (!typescript_1.default.isIdentifier(initializer)) {
|
|
143
148
|
continue;
|
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.scanSource = scanSource;
|
|
7
7
|
const typescript_1 = __importDefault(require("typescript"));
|
|
8
|
+
const utils_1 = require("../shared/utils");
|
|
8
9
|
const sinkDetector_1 = require("./sinkDetector");
|
|
9
10
|
const jsxEventAttribute_1 = require("./jsxEventAttribute");
|
|
10
11
|
const propagationAnalyzer_1 = require("./analyzer/propagationAnalyzer");
|
|
@@ -35,13 +36,19 @@ function scanSource(source, checker, wrapperRegistry, plugins) {
|
|
|
35
36
|
// track({
|
|
36
37
|
// event: name
|
|
37
38
|
// })
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
const argumentLiteral = (0, utils_1.unwrapExpression)(argument);
|
|
40
|
+
if (tracked.propertyPath.length > 0 && typescript_1.default.isObjectLiteralExpression(argumentLiteral)) {
|
|
41
|
+
const property = argumentLiteral.properties.find(prop => {
|
|
42
|
+
return ((typescript_1.default.isPropertyAssignment(prop) || typescript_1.default.isShorthandPropertyAssignment(prop)) &&
|
|
43
|
+
prop.name.getText() === tracked.propertyPath[0]);
|
|
41
44
|
});
|
|
42
45
|
if (property && typescript_1.default.isPropertyAssignment(property)) {
|
|
43
46
|
return property.initializer;
|
|
44
47
|
}
|
|
48
|
+
// track({ event })
|
|
49
|
+
if (property && typescript_1.default.isShorthandPropertyAssignment(property)) {
|
|
50
|
+
return property.name;
|
|
51
|
+
}
|
|
45
52
|
}
|
|
46
53
|
// track(name)
|
|
47
54
|
return argument;
|
|
@@ -12,12 +12,17 @@ function isEventraSdkModuleSpecifier(specifier) {
|
|
|
12
12
|
return (normalized === exports.EVENTRA_SDK_PACKAGE ||
|
|
13
13
|
normalized.startsWith(`${exports.EVENTRA_SDK_PACKAGE}/`));
|
|
14
14
|
}
|
|
15
|
+
/**
|
|
16
|
+
* Letters, digits and `: _ . / -`, plus the plain space - so third-party names such as
|
|
17
|
+
* `App: User Sign Up` survive. Tabs, newlines and every other character stay rejected.
|
|
18
|
+
*/
|
|
19
|
+
const EVENT_NAME_PATTERN = /^[a-zA-Z0-9:_./ -]+$/;
|
|
15
20
|
function normalizeEventName(value) {
|
|
16
21
|
const trimmed = value.trim();
|
|
17
22
|
if (!trimmed || trimmed.length > exports.MAX_EVENT_NAME_LENGTH) {
|
|
18
23
|
return null;
|
|
19
24
|
}
|
|
20
|
-
if (
|
|
25
|
+
if (!EVENT_NAME_PATTERN.test(trimmed)) {
|
|
21
26
|
return null;
|
|
22
27
|
}
|
|
23
28
|
return trimmed;
|
|
@@ -3,8 +3,20 @@ 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.getValueSymbol = getValueSymbol;
|
|
6
7
|
exports.getFunctionSymbol = getFunctionSymbol;
|
|
7
8
|
const typescript_1 = __importDefault(require("typescript"));
|
|
9
|
+
/**
|
|
10
|
+
* Symbol an identifier *refers to*. For the name in a shorthand property (`{ event }`) a plain
|
|
11
|
+
* `getSymbolAtLocation` yields the property's own symbol, not the variable/parameter it reads.
|
|
12
|
+
*/
|
|
13
|
+
function getValueSymbol(node, checker) {
|
|
14
|
+
const parent = node.parent;
|
|
15
|
+
if (parent && typescript_1.default.isShorthandPropertyAssignment(parent) && parent.name === node) {
|
|
16
|
+
return checker.getShorthandAssignmentValueSymbol(parent);
|
|
17
|
+
}
|
|
18
|
+
return checker.getSymbolAtLocation(node);
|
|
19
|
+
}
|
|
8
20
|
function getFunctionSymbol(node, checker) {
|
|
9
21
|
// function foo() {}
|
|
10
22
|
if (typescript_1.default.isFunctionDeclaration(node) && node.name) {
|
package/dist/cli/init.js
CHANGED
|
@@ -7,6 +7,31 @@ exports.init = init;
|
|
|
7
7
|
const chalk_1 = __importDefault(require("chalk"));
|
|
8
8
|
const inquirer_1 = __importDefault(require("inquirer"));
|
|
9
9
|
const config_1 = require("../config/config");
|
|
10
|
+
const sinkPresets_1 = require("../plugin/builtins/sinkPresets");
|
|
11
|
+
const projectDependencies_1 = require("./projectDependencies");
|
|
12
|
+
/**
|
|
13
|
+
* Looks for analytics libraries in package.json (and workspace members) and, if there are any,
|
|
14
|
+
* asks which of them Eventra should read events from. Everything detected is pre-selected.
|
|
15
|
+
*/
|
|
16
|
+
async function chooseSinks() {
|
|
17
|
+
const detected = (0, sinkPresets_1.detectSinkPresets)(await (0, projectDependencies_1.collectDependencyNames)(process.cwd()));
|
|
18
|
+
if (detected.length === 0)
|
|
19
|
+
return [];
|
|
20
|
+
console.log(chalk_1.default.gray(`Found analytics in package.json: ${detected.map((d) => d.packages.join(", ")).join("; ")}`));
|
|
21
|
+
const { presets } = await inquirer_1.default.prompt([
|
|
22
|
+
{
|
|
23
|
+
type: "checkbox",
|
|
24
|
+
name: "presets",
|
|
25
|
+
message: "Also read events from these analytics calls (sinks):",
|
|
26
|
+
choices: detected.map((d) => ({
|
|
27
|
+
name: `${d.preset} (${d.packages.join(", ")})`,
|
|
28
|
+
value: d.preset,
|
|
29
|
+
checked: true,
|
|
30
|
+
})),
|
|
31
|
+
},
|
|
32
|
+
]);
|
|
33
|
+
return presets.map((preset) => ({ preset }));
|
|
34
|
+
}
|
|
10
35
|
async function init() {
|
|
11
36
|
console.log(chalk_1.default.blue("Initializing Eventra..."));
|
|
12
37
|
const { apiKey, endpoint, } = await inquirer_1.default.prompt([
|
|
@@ -22,6 +47,7 @@ async function init() {
|
|
|
22
47
|
default: "https://api.eventra.dev/api/v1/cli/events",
|
|
23
48
|
},
|
|
24
49
|
]);
|
|
50
|
+
const sinks = await chooseSinks();
|
|
25
51
|
console.log("");
|
|
26
52
|
console.log(chalk_1.default.gray("Eventra detects @eventra_dev/eventra-sdk usage:"));
|
|
27
53
|
console.log(chalk_1.default.gray('• tracker.track("event.name")'));
|
|
@@ -33,6 +59,7 @@ async function init() {
|
|
|
33
59
|
endpoint,
|
|
34
60
|
events: [],
|
|
35
61
|
functionWrappers: [],
|
|
62
|
+
...(sinks.length > 0 ? { sinks } : {}),
|
|
36
63
|
sync: {
|
|
37
64
|
include: [
|
|
38
65
|
"**/*.{ts,tsx,js,jsx}",
|
|
@@ -47,6 +74,9 @@ async function init() {
|
|
|
47
74
|
};
|
|
48
75
|
await (0, config_1.saveConfig)(config);
|
|
49
76
|
console.log(chalk_1.default.green("eventra.json created"));
|
|
77
|
+
if (sinks.length > 0) {
|
|
78
|
+
console.log(chalk_1.default.green(`sinks: ${sinks.map((s) => s.preset).join(", ")}`));
|
|
79
|
+
}
|
|
50
80
|
// Never write the key into eventra.json — that file is meant to be
|
|
51
81
|
// committed (CI diffs it via `eventra check`). Keep secrets out of git.
|
|
52
82
|
if (apiKey) {
|
|
@@ -0,0 +1,119 @@
|
|
|
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.collectDependencyNames = collectDependencyNames;
|
|
7
|
+
const fast_glob_1 = __importDefault(require("fast-glob"));
|
|
8
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
const DEPENDENCY_FIELDS = ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"];
|
|
11
|
+
/** Upper bound on workspace members read, so a huge monorepo cannot make `init` crawl. */
|
|
12
|
+
const MAX_WORKSPACE_PACKAGES = 300;
|
|
13
|
+
/**
|
|
14
|
+
* Parsed package.json, or null when it is missing or not JSON. Whatever else the file holds (an
|
|
15
|
+
* array, a number) is returned as is: every reader below only looks up named properties on it, which
|
|
16
|
+
* is harmless on any JSON value, so there is no point in a second validation here.
|
|
17
|
+
*/
|
|
18
|
+
async function readPackage(file) {
|
|
19
|
+
try {
|
|
20
|
+
return (await fs_extra_1.default.readJSON(file));
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function dependencyNames(pkg) {
|
|
27
|
+
return DEPENDENCY_FIELDS.flatMap((field) => {
|
|
28
|
+
const deps = pkg[field];
|
|
29
|
+
return typeof deps === "object" && deps !== null && !Array.isArray(deps) ? Object.keys(deps) : [];
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function stringList(value) {
|
|
33
|
+
// Stryker disable next-line ArrayDeclaration: a stray pattern that matches no directory changes nothing
|
|
34
|
+
return Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
|
|
35
|
+
}
|
|
36
|
+
/** `packages:` entries of a pnpm-workspace.yaml (only the plain list form, which is all pnpm writes). */
|
|
37
|
+
function pnpmWorkspacePatterns(yaml) {
|
|
38
|
+
// Stryker disable next-line ArrayDeclaration: a stray pattern that matches no directory changes nothing
|
|
39
|
+
const patterns = [];
|
|
40
|
+
let inPackages = false;
|
|
41
|
+
for (const line of yaml.split(/\r?\n/)) {
|
|
42
|
+
if (/^packages:\s*(#.*)?$/.test(line)) {
|
|
43
|
+
inPackages = true;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (!inPackages)
|
|
47
|
+
continue;
|
|
48
|
+
// YAML lets the dashes sit at the key's own indent (`packages:` / `- 'apps/*'`), so no indent is required.
|
|
49
|
+
const item = /^\s*-\s*['"]?([^'"#]+?)['"]?\s*(#.*)?$/.exec(line);
|
|
50
|
+
if (item) {
|
|
51
|
+
patterns.push(item[1]);
|
|
52
|
+
}
|
|
53
|
+
else if (/^\S/.test(line)) {
|
|
54
|
+
// The next top-level key ends the list.
|
|
55
|
+
inPackages = false;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return patterns;
|
|
59
|
+
}
|
|
60
|
+
/** A workspace pattern may only point inside the project: no absolute paths, no `..`. */
|
|
61
|
+
function staysInsideProject(pattern) {
|
|
62
|
+
// Stryker disable next-line StringLiteral: a negated pattern outside the project excludes nothing, so what replaces the "!" cannot show
|
|
63
|
+
const target = pattern.replace(/^!/, "");
|
|
64
|
+
return !path_1.default.isAbsolute(target) && !target.split(/[\\/]/).includes("..");
|
|
65
|
+
}
|
|
66
|
+
/** pnpm-workspace.yaml patterns; an unreadable file (a directory, no permission) counts as having none. */
|
|
67
|
+
async function readPnpmPatterns(file) {
|
|
68
|
+
try {
|
|
69
|
+
return pnpmWorkspacePatterns(await fs_extra_1.default.readFile(file, "utf8"));
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Stryker disable next-line ArrayDeclaration: a stray pattern that matches no directory changes nothing
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function workspacePatterns(rootDir, rootPackage) {
|
|
77
|
+
const workspaces = rootPackage.workspaces;
|
|
78
|
+
const fromPackageJson = Array.isArray(workspaces)
|
|
79
|
+
? stringList(workspaces)
|
|
80
|
+
: stringList(workspaces?.packages);
|
|
81
|
+
const fromPnpm = await readPnpmPatterns(path_1.default.join(rootDir, "pnpm-workspace.yaml"));
|
|
82
|
+
return [...fromPackageJson, ...fromPnpm].filter(staysInsideProject);
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* `apps/*` -> every member's package.json. A negated pattern (`!apps/skipped`) becomes
|
|
86
|
+
* `!apps/skipped/package.json`, which fast-glob treats as an exclusion of exactly those files.
|
|
87
|
+
*/
|
|
88
|
+
const memberGlob = (pattern) => `${pattern}/package.json`;
|
|
89
|
+
/** Member package.json paths for the patterns; a pattern fast-glob rejects (a NUL byte, say) yields none. */
|
|
90
|
+
async function findMembers(rootDir, patterns) {
|
|
91
|
+
try {
|
|
92
|
+
return await (0, fast_glob_1.default)(patterns.map(memberGlob), { cwd: rootDir, ignore: ["**/node_modules/**"] });
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Stryker disable next-line ArrayDeclaration: a stray path that is not a package.json is read as "no package.json"
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Every dependency name the project declares: the root package.json plus, in a monorepo
|
|
101
|
+
* (`workspaces` or pnpm-workspace.yaml), each workspace member's package.json.
|
|
102
|
+
*/
|
|
103
|
+
async function collectDependencyNames(rootDir) {
|
|
104
|
+
const names = new Set();
|
|
105
|
+
const rootPackage = await readPackage(path_1.default.join(rootDir, "package.json"));
|
|
106
|
+
if (rootPackage === null)
|
|
107
|
+
return names;
|
|
108
|
+
for (const name of dependencyNames(rootPackage))
|
|
109
|
+
names.add(name);
|
|
110
|
+
const patterns = await workspacePatterns(rootDir, rootPackage);
|
|
111
|
+
const members = (await findMembers(rootDir, patterns)).slice(0, MAX_WORKSPACE_PACKAGES);
|
|
112
|
+
for (const member of members) {
|
|
113
|
+
const memberPackage = await readPackage(path_1.default.join(rootDir, member));
|
|
114
|
+
if (memberPackage !== null)
|
|
115
|
+
for (const name of dependencyNames(memberPackage))
|
|
116
|
+
names.add(name);
|
|
117
|
+
}
|
|
118
|
+
return names;
|
|
119
|
+
}
|
package/dist/config/config.js
CHANGED
|
@@ -21,6 +21,11 @@ exports.CONFIG_NAME = "eventra.json";
|
|
|
21
21
|
exports.LOCAL_CONFIG_NAME = "eventra.local.json";
|
|
22
22
|
const DEFAULT_INCLUDE = ["**/*.{ts,tsx,js,jsx}"];
|
|
23
23
|
const DEFAULT_EXCLUDE = ["node_modules", "dist", ".next", ".git"];
|
|
24
|
+
function normalizeSinks(sinks) {
|
|
25
|
+
if (!Array.isArray(sinks))
|
|
26
|
+
return [];
|
|
27
|
+
return sinks.filter((s) => typeof s === "object" && s !== null && !Array.isArray(s));
|
|
28
|
+
}
|
|
24
29
|
function normalizeConfig(config) {
|
|
25
30
|
const functionWrappers = (config.functionWrappers ?? [])
|
|
26
31
|
.filter((w) => w?.name)
|
|
@@ -32,12 +37,14 @@ function normalizeConfig(config) {
|
|
|
32
37
|
seen.add(w.name);
|
|
33
38
|
return true;
|
|
34
39
|
});
|
|
40
|
+
const sinks = normalizeSinks(config.sinks);
|
|
35
41
|
return {
|
|
36
42
|
apiKey: config.apiKey ?? "",
|
|
37
43
|
endpoint: config.endpoint ?? "",
|
|
38
44
|
events: [...(config.events ?? [])].sort(),
|
|
39
45
|
functionWrappers: uniqueWrappers.sort((a, b) => a.name.localeCompare(b.name)),
|
|
40
46
|
plugins: [...(config.plugins ?? [])],
|
|
47
|
+
...(sinks.length > 0 ? { sinks } : {}),
|
|
41
48
|
sync: {
|
|
42
49
|
include: config.sync?.include ?? DEFAULT_INCLUDE,
|
|
43
50
|
exclude: config.sync?.exclude ?? DEFAULT_EXCLUDE,
|
|
@@ -0,0 +1,253 @@
|
|
|
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.resolveSinkRules = resolveSinkRules;
|
|
7
|
+
exports.createConfiguredSinkDetector = createConfiguredSinkDetector;
|
|
8
|
+
exports.registerConfiguredSinks = registerConfiguredSinks;
|
|
9
|
+
const typescript_1 = __importDefault(require("typescript"));
|
|
10
|
+
const utils_1 = require("../../analysis/shared/utils");
|
|
11
|
+
const sinkPresets_1 = require("./sinkPresets");
|
|
12
|
+
/** Expands presets and drops malformed rules. `warn` receives a message per dropped rule. */
|
|
13
|
+
function resolveSinkRules(rules, warn) {
|
|
14
|
+
const resolved = [];
|
|
15
|
+
for (const rule of rules) {
|
|
16
|
+
if (rule.preset !== undefined) {
|
|
17
|
+
const preset = typeof rule.preset === "string" && Object.hasOwn(sinkPresets_1.SINK_PRESETS, rule.preset) ? sinkPresets_1.SINK_PRESETS[rule.preset] : undefined;
|
|
18
|
+
if (!preset) {
|
|
19
|
+
warn(`Eventra: unknown sinks preset "${rule.preset}" (available: ${Object.keys(sinkPresets_1.SINK_PRESETS).join(", ")}) - skipped.`);
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
resolved.push(...preset);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
// eventra.json is hand-edited JSON: every field can hold any type, and a wrong one must be skipped, not crash.
|
|
26
|
+
const callee = typeof rule.callee === "string" ? rule.callee.trim() : undefined;
|
|
27
|
+
if (!callee || !/^[A-Za-z_$][\w$]*(\.[A-Za-z_$][\w$]*)*$/.test(callee)) {
|
|
28
|
+
warn(`Eventra: sinks rule needs a "preset" or a dotted "callee" like "analytics.track" - skipped.`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const nameArg = rule.nameArg ?? 0;
|
|
32
|
+
if (!Number.isInteger(nameArg) || nameArg < 0) {
|
|
33
|
+
warn(`Eventra: sinks rule "${callee}" has an invalid "nameArg" - skipped.`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (rule.nameProperty !== undefined && typeof rule.nameProperty !== "string") {
|
|
37
|
+
warn(`Eventra: sinks rule "${callee}" has an invalid "nameProperty" - skipped.`);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (rule.modules !== undefined &&
|
|
41
|
+
(!Array.isArray(rule.modules) || !rule.modules.every((m) => typeof m === "string" && m !== ""))) {
|
|
42
|
+
warn(`Eventra: sinks rule "${callee}" has an invalid "modules" - skipped.`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
let when;
|
|
46
|
+
if (rule.when !== undefined) {
|
|
47
|
+
const candidate = rule.when;
|
|
48
|
+
if (!candidate ||
|
|
49
|
+
!Number.isInteger(candidate.arg) ||
|
|
50
|
+
candidate.arg < 0 ||
|
|
51
|
+
typeof candidate.equals !== "string" ||
|
|
52
|
+
candidate.equals === "") {
|
|
53
|
+
warn(`Eventra: sinks rule "${callee}" has an invalid "when" - skipped.`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
when = { arg: candidate.arg, equals: candidate.equals };
|
|
57
|
+
}
|
|
58
|
+
resolved.push({
|
|
59
|
+
callee,
|
|
60
|
+
nameArg,
|
|
61
|
+
...(rule.nameProperty ? { nameProperty: rule.nameProperty } : {}),
|
|
62
|
+
...(rule.modules?.length ? { modules: rule.modules } : {}),
|
|
63
|
+
...(when ? { when } : {}),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return resolved;
|
|
67
|
+
}
|
|
68
|
+
/** `a.b.c` / `this.a.b` / `a?.b` -> "a.b.c"; null for anything that isn't a plain property path. */
|
|
69
|
+
function calleePath(expr) {
|
|
70
|
+
const e = (0, utils_1.unwrapExpression)(expr);
|
|
71
|
+
if (typescript_1.default.isIdentifier(e))
|
|
72
|
+
return e.text;
|
|
73
|
+
if (e.kind === typescript_1.default.SyntaxKind.ThisKeyword)
|
|
74
|
+
return "this";
|
|
75
|
+
if (typescript_1.default.isPropertyAccessExpression(e)) {
|
|
76
|
+
const head = calleePath(e.expression);
|
|
77
|
+
return head === null ? null : `${head}.${e.name.text}`;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
function moduleMatches(specifier, modules) {
|
|
82
|
+
return modules.some((m) => specifier === m || specifier.startsWith(`${m}/`));
|
|
83
|
+
}
|
|
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
|
+
/**
|
|
100
|
+
* The symbol an identifier refers to, keeping an import as the import binding itself. TS resolves an
|
|
101
|
+
* import all the way to its target in some positions once something makes the module resolvable
|
|
102
|
+
* (a wildcard `declare module "*"` does that for every unresolved package), and the trail to the
|
|
103
|
+
* package's name is gone by then.
|
|
104
|
+
*/
|
|
105
|
+
function symbolAt(id, checker) {
|
|
106
|
+
const symbol = checker.getSymbolAtLocation(id);
|
|
107
|
+
// Stryker disable next-line ConditionalExpression: fast path only; the scope lookup below returns the same alias
|
|
108
|
+
if (symbol && symbol.flags & typescript_1.default.SymbolFlags.Alias)
|
|
109
|
+
return symbol;
|
|
110
|
+
return checker.getSymbolsInScope(id, typescript_1.default.SymbolFlags.Alias).find((alias) => alias.name === id.text) ?? symbol;
|
|
111
|
+
}
|
|
112
|
+
/** The symbol `name` exported by the module a specifier points at, when TS can resolve that module. */
|
|
113
|
+
function resolveExport(specifier, name, checker) {
|
|
114
|
+
const moduleSymbol = checker.getSymbolAtLocation(specifier);
|
|
115
|
+
return moduleSymbol ? checker.tryGetMemberInModuleExports(name, moduleSymbol) : undefined;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* `usePostHog()`, `new PostHog(k)`, `await init()`, `this.client`, `this.getClient()`,
|
|
119
|
+
* `AnalyticsBrowser.load()` or a bare alias `usePostHog`: true when the name at the heart of the
|
|
120
|
+
* expression, or the import its chain hangs off, originates from `modules`.
|
|
121
|
+
*/
|
|
122
|
+
function expressionFromModules(expression, checker, modules, hops) {
|
|
123
|
+
let expr = (0, utils_1.unwrapExpression)(expression);
|
|
124
|
+
if (typescript_1.default.isAwaitExpression(expr))
|
|
125
|
+
expr = (0, utils_1.unwrapExpression)(expr.expression);
|
|
126
|
+
if (typescript_1.default.isCallExpression(expr) || typescript_1.default.isNewExpression(expr))
|
|
127
|
+
expr = (0, utils_1.unwrapExpression)(expr.expression);
|
|
128
|
+
const fromName = (name) => typescript_1.default.isIdentifier(name) && symbolFromModules(symbolAt(name, checker), checker, modules, hops);
|
|
129
|
+
if (!typescript_1.default.isPropertyAccessExpression(expr))
|
|
130
|
+
return fromName(expr);
|
|
131
|
+
// `this.client` is vetted through the property's own declaration; `AnalyticsBrowser.load()` and
|
|
132
|
+
// `amplitude.getInstance()` hang off an import, so the chain's root decides.
|
|
133
|
+
let root = expr;
|
|
134
|
+
while (typescript_1.default.isPropertyAccessExpression(root))
|
|
135
|
+
root = (0, utils_1.unwrapExpression)(root.expression);
|
|
136
|
+
return fromName(expr.name) || fromName(root);
|
|
137
|
+
}
|
|
138
|
+
function declarationFromModules(decl, checker, modules, hops) {
|
|
139
|
+
if (typescript_1.default.isImportSpecifier(decl) || typescript_1.default.isImportClause(decl) || typescript_1.default.isNamespaceImport(decl)) {
|
|
140
|
+
// Every import binding sits under an ImportDeclaration, whose specifier is a string literal.
|
|
141
|
+
const importDecl = typescript_1.default.findAncestor(decl, typescript_1.default.isImportDeclaration);
|
|
142
|
+
if (moduleMatches(importDecl.moduleSpecifier.text, modules))
|
|
143
|
+
return true;
|
|
144
|
+
if (typescript_1.default.isNamespaceImport(decl))
|
|
145
|
+
return false;
|
|
146
|
+
const exported = typescript_1.default.isImportSpecifier(decl) ? (decl.propertyName ?? decl.name).text : "default";
|
|
147
|
+
return symbolFromModules(resolveExport(importDecl.moduleSpecifier, exported, checker), checker, modules, hops + 1);
|
|
148
|
+
}
|
|
149
|
+
if (typescript_1.default.isExportSpecifier(decl)) {
|
|
150
|
+
const specifier = decl.parent.parent.moduleSpecifier;
|
|
151
|
+
if (!specifier) {
|
|
152
|
+
// export { local as alias }
|
|
153
|
+
return symbolFromModules(checker.getExportSpecifierLocalTargetSymbol(decl), checker, modules, hops + 1);
|
|
154
|
+
}
|
|
155
|
+
// export { name as alias } from "module"
|
|
156
|
+
if (moduleMatches(specifier.text, modules))
|
|
157
|
+
return true;
|
|
158
|
+
const exported = (decl.propertyName ?? decl.name).text;
|
|
159
|
+
return symbolFromModules(resolveExport(specifier, exported, checker), checker, modules, hops + 1);
|
|
160
|
+
}
|
|
161
|
+
// export default client
|
|
162
|
+
if (typescript_1.default.isExportAssignment(decl)) {
|
|
163
|
+
return expressionFromModules(decl.expression, checker, modules, hops + 1);
|
|
164
|
+
}
|
|
165
|
+
// const { posthog } = usePostHog(): the binding's own declaration is the enclosing variable.
|
|
166
|
+
const owner = typescript_1.default.isBindingElement(decl) ? decl.parent.parent : decl;
|
|
167
|
+
// Variables, properties, parameters, functions (return type) and type aliases all keep their
|
|
168
|
+
// annotation in `type`; variables, properties and parameters keep a default/initial value in
|
|
169
|
+
// `initializer`. Anything else (classes, interfaces, ...) simply has neither.
|
|
170
|
+
const { type, initializer } = owner;
|
|
171
|
+
// 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))) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
// const ph = usePostHog() / new PostHog(k) / client = new PostHog(k) / const useTrack = usePostHog
|
|
177
|
+
return initializer !== undefined && expressionFromModules(initializer, checker, modules, hops + 1);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* True when `symbol` originates from one of `modules`, following import/re-export chains
|
|
181
|
+
* (`import { useAnalytics } from "@/hooks"` -> `export { usePostHog as useAnalytics } from "posthog-js/react"`).
|
|
182
|
+
* Works without installed dependencies, by reading the specifiers along the chain.
|
|
183
|
+
*/
|
|
184
|
+
function symbolFromModules(symbol, checker, modules, hops = 0) {
|
|
185
|
+
if (!symbol || hops > MAX_ALIAS_HOPS)
|
|
186
|
+
return false;
|
|
187
|
+
// A real ts.Symbol's `declarations` is only undefined for synthetic symbols we never see here.
|
|
188
|
+
// Stryker disable next-line ArrayDeclaration: unreachable fallback, see above
|
|
189
|
+
/* v8 ignore next */
|
|
190
|
+
const declarations = symbol.declarations ?? [];
|
|
191
|
+
return declarations.some((decl) => declarationFromModules(decl, checker, modules, hops));
|
|
192
|
+
}
|
|
193
|
+
/** `rule.when`: the guard argument must be the exact string literal (`gtag("event", ...)` but not `gtag("config", ...)`). */
|
|
194
|
+
function whenSatisfied(call, rule) {
|
|
195
|
+
if (!rule.when)
|
|
196
|
+
return true;
|
|
197
|
+
const guard = call.arguments[rule.when.arg];
|
|
198
|
+
const literal = guard && (0, utils_1.unwrapExpression)(guard);
|
|
199
|
+
return literal !== undefined && typescript_1.default.isStringLiteralLike(literal) && literal.text === rule.when.equals;
|
|
200
|
+
}
|
|
201
|
+
function trackedArgumentFor(call, rule) {
|
|
202
|
+
const arg = call.arguments[rule.nameArg];
|
|
203
|
+
if (!arg)
|
|
204
|
+
return null;
|
|
205
|
+
const literal = (0, utils_1.unwrapExpression)(arg);
|
|
206
|
+
if (typescript_1.default.isObjectLiteralExpression(literal)) {
|
|
207
|
+
// Only `{ event: name }` is a usable shape; the scanner reads this property off the literal.
|
|
208
|
+
// (`SpreadAssignment` has no name, hence the kind check.)
|
|
209
|
+
const found = literal.properties.some((p) => (typescript_1.default.isPropertyAssignment(p) || typescript_1.default.isShorthandPropertyAssignment(p)) && p.name.getText() === rule.nameProperty);
|
|
210
|
+
return found ? { index: rule.nameArg, propertyPath: [rule.nameProperty] } : null;
|
|
211
|
+
}
|
|
212
|
+
// Stryker disable next-line ArrayDeclaration: propertyPath is only read for object-literal arguments
|
|
213
|
+
return { index: rule.nameArg, propertyPath: [] };
|
|
214
|
+
}
|
|
215
|
+
function createConfiguredSinkDetector(rules) {
|
|
216
|
+
return {
|
|
217
|
+
name: "configured-sinks",
|
|
218
|
+
detect({ call, checker }) {
|
|
219
|
+
const callee = (0, utils_1.unwrapExpression)(call.expression);
|
|
220
|
+
// `gtag(...)` / `x.capture(...)`: the name actually being called.
|
|
221
|
+
const last = typescript_1.default.isIdentifier(callee) ? callee.text : typescript_1.default.isPropertyAccessExpression(callee) ? callee.name.text : null;
|
|
222
|
+
const path = calleePath(callee);
|
|
223
|
+
for (const rule of rules) {
|
|
224
|
+
// A dotted rule needs the whole plain path (`foo().analytics.track` has none); a bare name
|
|
225
|
+
// matches whatever receiver it hangs off, including a call (`usePostHog().capture`).
|
|
226
|
+
const matched = rule.callee.includes(".")
|
|
227
|
+
? path !== null && (path === rule.callee || path.endsWith(`.${rule.callee}`))
|
|
228
|
+
: last === rule.callee;
|
|
229
|
+
if (!matched || !whenSatisfied(call, rule))
|
|
230
|
+
continue;
|
|
231
|
+
// A bare name is too generic to match alone: a receiver-vetted rule checks where the receiver
|
|
232
|
+
// (`x` in `x.capture(...)`) or, for a bare call, the called name itself comes from.
|
|
233
|
+
if (rule.modules &&
|
|
234
|
+
!expressionFromModules(typescript_1.default.isPropertyAccessExpression(callee) ? callee.expression : callee, checker, rule.modules, 0)) {
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
const tracked = trackedArgumentFor(call, rule);
|
|
238
|
+
if (tracked)
|
|
239
|
+
return { call, trackedArguments: [tracked] };
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
/** Registers the `sinks` rules from eventra.json (declarative data only - never code). */
|
|
246
|
+
function registerConfiguredSinks(registry, rules, warn = (m) => console.warn(m)) {
|
|
247
|
+
if (!rules?.length)
|
|
248
|
+
return;
|
|
249
|
+
const resolved = resolveSinkRules(rules, warn);
|
|
250
|
+
if (resolved.length === 0)
|
|
251
|
+
return;
|
|
252
|
+
registry.registerSinkDetector(createConfiguredSinkDetector(resolved));
|
|
253
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Built-in `sinks` presets, plus the npm packages each one is recognised by.
|
|
4
|
+
*
|
|
5
|
+
* Package names and what they export were checked against the npm registry and the packages' own
|
|
6
|
+
* type declarations on 2026-10-08 (`pnpm check:presets` re-checks the names against the registry,
|
|
7
|
+
* so a rename or a deprecation shows up there instead of as a silent detection gap).
|
|
8
|
+
*/
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.SINK_PRESETS = exports.PRESET_PACKAGES = void 0;
|
|
11
|
+
exports.detectSinkPresets = detectSinkPresets;
|
|
12
|
+
const pkg = (name, flags = {}) => ({ name, ...flags });
|
|
13
|
+
exports.PRESET_PACKAGES = {
|
|
14
|
+
posthog: [
|
|
15
|
+
pkg("posthog-js"),
|
|
16
|
+
pkg("@posthog/react"),
|
|
17
|
+
pkg("posthog-js-lite"),
|
|
18
|
+
pkg("posthog-node"),
|
|
19
|
+
pkg("posthog-react-native"),
|
|
20
|
+
],
|
|
21
|
+
segment: [
|
|
22
|
+
pkg("@segment/analytics-next"),
|
|
23
|
+
pkg("@segment/analytics-node"),
|
|
24
|
+
pkg("@segment/analytics-react-native"),
|
|
25
|
+
pkg("@segment/analytics.js-core", { legacy: true }),
|
|
26
|
+
pkg("analytics-node", { legacy: true }),
|
|
27
|
+
pkg("@segment/snippet", { detectOnly: true }),
|
|
28
|
+
],
|
|
29
|
+
amplitude: [
|
|
30
|
+
pkg("@amplitude/analytics-browser"),
|
|
31
|
+
pkg("@amplitude/analytics-node"),
|
|
32
|
+
pkg("@amplitude/analytics-react-native"),
|
|
33
|
+
pkg("@amplitude/marketing-analytics-browser"),
|
|
34
|
+
pkg("@amplitude/unified"),
|
|
35
|
+
pkg("amplitude-js", { legacy: true }),
|
|
36
|
+
],
|
|
37
|
+
ga4: [
|
|
38
|
+
pkg("react-ga4"),
|
|
39
|
+
pkg("vue-gtag"),
|
|
40
|
+
pkg("vue-gtag-next", { legacy: true }),
|
|
41
|
+
pkg("@next/third-parties", { detectOnly: true }),
|
|
42
|
+
pkg("nuxt-gtag", { detectOnly: true }),
|
|
43
|
+
pkg("@types/gtag.js", { detectOnly: true }),
|
|
44
|
+
],
|
|
45
|
+
};
|
|
46
|
+
/** Packages a rule can vet a receiver against: everything except the detect-only signals. */
|
|
47
|
+
const modulesOf = (preset, only) => exports.PRESET_PACKAGES[preset]
|
|
48
|
+
.filter((p) => !p.detectOnly && (only === undefined || only.includes(p.name)))
|
|
49
|
+
.map((p) => p.name);
|
|
50
|
+
exports.SINK_PRESETS = {
|
|
51
|
+
posthog: [
|
|
52
|
+
// posthog.capture("x") - the conventional name, posthog-js and posthog-node alike.
|
|
53
|
+
{ callee: "posthog.capture", nameArg: 0, nameProperty: "event" },
|
|
54
|
+
// Any other receiver name, e.g. `const ph = usePostHog(); ph.capture("x")` or
|
|
55
|
+
// `const client = new PostHog(key); client.capture({ distinctId, event: "x" })`.
|
|
56
|
+
{ callee: "capture", nameArg: 0, nameProperty: "event", modules: modulesOf("posthog") },
|
|
57
|
+
],
|
|
58
|
+
segment: [
|
|
59
|
+
// analytics.track("x", props) from analytics.js / @segment/analytics-next, `window.analytics` included;
|
|
60
|
+
// analytics-node takes `analytics.track({ userId, event: "x" })`.
|
|
61
|
+
{ callee: "analytics.track", nameArg: 0, nameProperty: "event" },
|
|
62
|
+
// `const [analytics] = await AnalyticsBrowser.load(...)`, `new Analytics({ writeKey })`, any variable name.
|
|
63
|
+
{ callee: "track", nameArg: 0, nameProperty: "event", modules: modulesOf("segment") },
|
|
64
|
+
],
|
|
65
|
+
amplitude: [
|
|
66
|
+
// amplitude.track("x") / amplitude.track({ event_type: "x" }) / the legacy amplitude.logEvent("x").
|
|
67
|
+
{ callee: "amplitude.track", nameArg: 0, nameProperty: "event_type" },
|
|
68
|
+
{ callee: "amplitude.logEvent", nameArg: 0, nameProperty: "event_type" },
|
|
69
|
+
// `amplitude.getInstance().logEvent("x")`, `const client = Amplitude.createInstance()`, any variable name.
|
|
70
|
+
{ callee: "track", nameArg: 0, nameProperty: "event_type", modules: modulesOf("amplitude") },
|
|
71
|
+
{ callee: "logEvent", nameArg: 0, nameProperty: "event_type", modules: modulesOf("amplitude") },
|
|
72
|
+
],
|
|
73
|
+
ga4: [
|
|
74
|
+
// gtag("event", "x", params): the name is the second argument, and only for the "event" command
|
|
75
|
+
// (`gtag("config", id)` and `gtag("set", ...)` are not events). `window.gtag` and nuxt-gtag's
|
|
76
|
+
// `const { gtag } = useGtag()` match too.
|
|
77
|
+
{ callee: "gtag", nameArg: 1, when: { arg: 0, equals: "event" } },
|
|
78
|
+
// @next/third-parties: sendGAEvent("event", "x", params).
|
|
79
|
+
{ callee: "sendGAEvent", nameArg: 1, when: { arg: 0, equals: "event" } },
|
|
80
|
+
// nuxt-gtag: useTrackEvent("x", params), auto-imported.
|
|
81
|
+
{ callee: "useTrackEvent", nameArg: 0 },
|
|
82
|
+
// vue-gtag: this.$gtag.event("x", params).
|
|
83
|
+
{ callee: "$gtag.event", nameArg: 0 },
|
|
84
|
+
// vue-gtag: `import { event } from "vue-gtag"` or `const { event } = useGtag()`.
|
|
85
|
+
{ callee: "event", nameArg: 0, modules: modulesOf("ga4", ["vue-gtag", "vue-gtag-next"]) },
|
|
86
|
+
// react-ga4: ReactGA.event("x", params) or ReactGA.event({ category, action: "x" }).
|
|
87
|
+
{ callee: "event", nameArg: 0, nameProperty: "action", modules: modulesOf("ga4", ["react-ga4"]) },
|
|
88
|
+
],
|
|
89
|
+
};
|
|
90
|
+
/** Which presets a project's dependency names point at, in preset order. */
|
|
91
|
+
function detectSinkPresets(dependencyNames) {
|
|
92
|
+
const detected = [];
|
|
93
|
+
for (const [preset, packages] of Object.entries(exports.PRESET_PACKAGES)) {
|
|
94
|
+
const present = packages.map((p) => p.name).filter((name) => dependencyNames.has(name));
|
|
95
|
+
if (present.length > 0)
|
|
96
|
+
detected.push({ preset, packages: present });
|
|
97
|
+
}
|
|
98
|
+
return detected;
|
|
99
|
+
}
|
|
@@ -37,6 +37,7 @@ exports.isTrustedPluginSpecifier = isTrustedPluginSpecifier;
|
|
|
37
37
|
exports.createBuiltinPluginRegistry = createBuiltinPluginRegistry;
|
|
38
38
|
exports.createPluginRegistry = createPluginRegistry;
|
|
39
39
|
const external_1 = require("./adapters/external");
|
|
40
|
+
const configuredSinks_1 = require("./builtins/configuredSinks");
|
|
40
41
|
const eventra_sdk_1 = require("./builtins/eventra-sdk");
|
|
41
42
|
const consoleDynamicEventReporter_1 = require("./builtins/consoleDynamicEventReporter");
|
|
42
43
|
const registry_1 = require("./registry");
|
|
@@ -107,6 +108,7 @@ function createBuiltinPluginRegistry() {
|
|
|
107
108
|
/** Built-ins + plugins listed in eventra.json `plugins` array. */
|
|
108
109
|
async function createPluginRegistry(config) {
|
|
109
110
|
const registry = createBuiltinPluginRegistry();
|
|
111
|
+
(0, configuredSinks_1.registerConfiguredSinks)(registry, config.sinks);
|
|
110
112
|
for (const spec of config.plugins ?? []) {
|
|
111
113
|
const trimmed = spec.trim();
|
|
112
114
|
if (!trimmed)
|
package/package.json
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eventra_dev/eventra-cli",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Static analytics event extraction CLI powered by the TypeScript compiler API",
|
|
5
5
|
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "and-1991",
|
|
8
|
+
"url": "https://github.com/and-1991"
|
|
9
|
+
},
|
|
6
10
|
"type": "commonjs",
|
|
7
11
|
"bin": {
|
|
8
12
|
"eventra": "./dist/index.js"
|
|
@@ -31,7 +35,9 @@
|
|
|
31
35
|
"test:unit": "vitest run",
|
|
32
36
|
"test:coverage": "vitest run --coverage",
|
|
33
37
|
"test:e2e": "pnpm run clean && pnpm run build && tsx tests/run.ts",
|
|
34
|
-
"clean": "rm -rf dist"
|
|
38
|
+
"clean": "rm -rf dist",
|
|
39
|
+
"test:mutation": "stryker run",
|
|
40
|
+
"check:presets": "tsx scripts/check-preset-packages.ts"
|
|
35
41
|
},
|
|
36
42
|
"dependencies": {
|
|
37
43
|
"chalk": "^4.1.2",
|
|
@@ -43,6 +49,8 @@
|
|
|
43
49
|
"typescript": "^5.3.0"
|
|
44
50
|
},
|
|
45
51
|
"devDependencies": {
|
|
52
|
+
"@stryker-mutator/core": "9.6.1",
|
|
53
|
+
"@stryker-mutator/vitest-runner": "9.6.1",
|
|
46
54
|
"@types/fs-extra": "^11.0.4",
|
|
47
55
|
"@types/inquirer": "^9.0.9",
|
|
48
56
|
"@types/node": "^20.0.0",
|