@eventra_dev/eventra-cli 2.0.10 → 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 CHANGED
@@ -1,9 +1,21 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Eventra
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-445%20passing-brightgreen?style=flat-square&logo=vitest&logoColor=white">
12
- <img alt="e2e fixtures" src="https://img.shields.io/badge/e2e-12%20fixtures-brightgreen?style=flat-square">
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, **445 unit tests + 12 e2e fixtures + 3 `check` exit-code scenarios + 1 `watch` scenario**.
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)** — 39 suites covering core modules:
454
+ **Unit tests (vitest)** - 47 suites covering core modules:
390
455
 
391
456
  | Module | Covers |
392
457
  |---|---|
@@ -411,8 +476,14 @@ Two test layers, **445 unit tests + 12 e2e fixtures + 3 `check` exit-code scenar
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** — 12 isolated TS projects scanned via `eventra sync`:
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 resolved = (0, callResolver_1.resolveFunctionFromCall)(call.expression, checker, resolvedCallCache, exportCache);
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.arguments[propagation.sourceParameterIndex] ?? propagation.sourceParameter.initializer;
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 || !typescript_1.default.isIdentifier(propagation.targetNode)) {
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
- const bindingSymbol = checker.getSymbolAtLocation(propagation.targetNode);
109
- if (!bindingSymbol) {
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.expression.getText(), resolved.values, dynamicOccurrences);
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 = checker.getSymbolAtLocation(node);
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
- if (!typescript_1.default.isVariableDeclaration(declaration) || !declaration.initializer || !typescript_1.default.isObjectLiteralExpression(declaration.initializer)) {
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 declaration.initializer.properties) {
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 = checker.getSymbolAtLocation(node);
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
- if (tracked.propertyPath.length > 0 && typescript_1.default.isObjectLiteralExpression(argument)) {
39
- const property = argument.properties.find(prop => {
40
- return (typescript_1.default.isPropertyAssignment(prop) && prop.name.getText() === tracked.propertyPath[0]);
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 (!/^[a-zA-Z0-9:_./-]+$/.test(trimmed)) {
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
+ }
@@ -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.10",
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",