@eventra_dev/eventra-cli 2.0.10 → 2.2.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-783%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. If `eventra.json` already exists, `init` asks before overwriting it (the default answer is no). `eventra init --yes` never prompts: it uses the default endpoint, accepts every detected preset, and leaves an existing `eventra.json` untouched, so it is safe in scripts and CI.
216
+
154
217
  ### `eventra sync`
155
218
 
156
219
  Full project scan → updates `eventra.json`:
@@ -158,9 +221,11 @@ Full project scan → updates `eventra.json`:
158
221
  - `events` — discovered event names
159
222
  - `functionWrappers` — detected wrapper functions
160
223
 
224
+ A file that cannot be analysed (a broken `.vue`, `.svelte`, `.astro` or `.html`, an unreadable file, or one over 2 MB) is skipped, and `sync` prints one summary at the end listing each skipped file and why. While anything is skipped, events already in `eventra.json` are kept, because a missing event may simply live in a skipped file. The exit code stays `0`; `eventra sync --strict` exits `1` if any file was skipped.
225
+
161
226
  ### `eventra check`
162
227
 
163
- Compares config with the current scan (events + wrappers). Exit code `1` on drift.
228
+ Compares config with the current scan (events + wrappers). Exit code `1` on drift. When files were skipped, known events that were not found are not reported as removed (they cannot be verified) and the skipped files are listed; `eventra check --strict` also exits `1` if any file was skipped.
164
229
 
165
230
  ### `eventra check --fix`
166
231
 
@@ -192,6 +257,14 @@ This records the endpoint in `eventra.local.json` (gitignored). If `eventra.json
192
257
 
193
258
  Once `eventra.json` already commits an `endpoint`, that committed value is what events actually get sent to - `EVENTRA_ENDPOINT` only controls whether the approval prompt is skipped; its own value is not read or compared against anything in that case. `EVENTRA_ENDPOINT` becomes the real destination only when `eventra.json` has no `endpoint` set at all. In other words, setting the variable to point somewhere else does not redirect traffic away from an already-committed endpoint - it only means you won't be asked to approve it.
194
259
 
260
+ **Catalog snapshot.** Besides the event list, `send` scans the project once more and tells the server how trustworthy that list is, so the dashboard can compare what is in your code with what actually arrived. It adds four optional fields to the request: `complete`, `dynamicCount`, `branch` and `cliVersion`.
261
+
262
+ - `complete` is `true` only when the scan skipped no file, hit no error, and `eventra.json` matches what the scan just found. The server marks names that are missing from a complete snapshot as removed from code, so anything doubtful is `false` (a file that could not be parsed, a failed scan, or an `eventra.json` that is out of date: run `eventra sync` first). A request that is not complete never removes anything.
263
+ - `dynamicCount` is the number of event-name call sites that could not be resolved to any string (for example `track(name)` with an unknown `name`). The dashboard uses it to warn that such names may show up as unexpected events.
264
+ - `branch` is the current branch, read from the usual CI variables (`GITHUB_REF_NAME`, `CI_COMMIT_REF_NAME`, `BRANCH_NAME`, `BITBUCKET_BRANCH`, `CIRCLE_BRANCH`, `BUILDKITE_BRANCH`, `VERCEL_GIT_COMMIT_REF`) and otherwise from `git`; it is left out when unknown (for example a detached checkout with no CI variable). `cliVersion` is the CLI version.
265
+
266
+ Run `send` from the main branch in CI: a snapshot from a feature branch describes that branch. A server that predates these fields rejects them, so `send` repeats the request once with the plain event list. The extra scan adds the same time as one `eventra sync`.
267
+
195
268
  Network resilience:
196
269
 
197
270
  - Up to 4 attempts with exponential backoff + jitter (capped at 8 s)
@@ -338,6 +411,7 @@ No dependency on `@eventra_dev/eventra-cli` is required — the host CLI loads t
338
411
  "events": [],
339
412
  "functionWrappers": [],
340
413
  "plugins": [],
414
+ "sinks": [],
341
415
  "sync": {
342
416
  "include": ["**/*.{ts,tsx,js,jsx}"],
343
417
  "exclude": ["node_modules", "dist", ".next", ".git"]
@@ -350,8 +424,9 @@ No dependency on `@eventra_dev/eventra-cli` is required — the host CLI loads t
350
424
  | `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
425
  | `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
426
  | `plugins` | `@eventra_dev/cli-plugin-*` specifiers to `import()` at startup. Anything outside that scope is skipped (see [Plugin contract](#plugin-contract-for-authors)) |
427
+ | `sinks` | Third-party analytics calls to treat as event sources, e.g. `[{ "preset": "posthog" }]` (see [Third-party analytics](#third-party-analytics-sinks)) |
353
428
  | `sync.include` | Base glob patterns; plugin `includeGlobs` are merged automatically |
354
- | `sync.exclude` | Paths skipped during scan |
429
+ | `sync.exclude` | Paths skipped during scan. A plain name such as `node_modules` or `dist` matches at any depth (nested `packages/*/node_modules` too); entries containing `/` or glob characters are used as written |
355
430
 
356
431
  `eventra init`/`eventra send` never write a real key into this file — they write to `eventra.local.json` instead, which is automatically added to `.gitignore`.
357
432
 
@@ -375,18 +450,18 @@ No runtime execution. No monkey-patching.
375
450
 
376
451
  ## Requirements
377
452
 
378
- - Node.js 18+
453
+ - Node.js 18+ (`eventra watch` needs Node.js 20.19+, which its file-watching dependency requires; `init`, `sync`, `check` and `send` run on 18)
379
454
  - TypeScript/JavaScript source using `@eventra_dev/eventra-sdk`
380
455
 
381
456
  ---
382
457
 
383
458
  ## Test Coverage
384
459
 
385
- Two test layers, **445 unit tests + 12 e2e fixtures + 3 `check` exit-code scenarios + 1 `watch` scenario**.
460
+ Two test layers, **783 unit tests + 14 e2e fixtures + 3 `check` exit-code scenarios + 1 `watch` scenario**.
386
461
 
387
462
  **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
463
 
389
- **Unit tests (vitest)** — 39 suites covering core modules:
464
+ **Unit tests (vitest)** - 47 suites covering core modules:
390
465
 
391
466
  | Module | Covers |
392
467
  |---|---|
@@ -411,8 +486,14 @@ Two test layers, **445 unit tests + 12 e2e fixtures + 3 `check` exit-code scenar
411
486
  | `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
487
  | `send()` | Endpoint trust-on-first-use (TOFU): default-trusted, blocked-until-approved, approve-then-persists, re-block-on-change, env-var-bypasses-approval |
413
488
  | `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 |
489
+ | `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 |
490
+ | `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 |
491
+ | `project dependencies` | package.json and workspace reading for `eventra init` (`workspaces`, pnpm-workspace.yaml, negations, caps, patterns that leave the project, unreadable files) |
492
+ | `init` | `eventra init` prompts, pre-selected presets, written config, exact console output, API key kept out of `eventra.json` |
493
+ | `preset package check` | The registry verdicts behind `pnpm check:presets` (missing, deprecated, stale, legacy) |
494
+ | `license` | `LICENSE` is the complete MIT text, its holder is the package author, and both match `package.json` |
414
495
 
415
- **End-to-end fixtures** — 12 isolated TS projects scanned via `eventra sync`:
496
+ **End-to-end fixtures** - 14 isolated TS projects scanned via `eventra sync`:
416
497
 
417
498
  | Fixture | Covers |
418
499
  |---|---|
@@ -423,6 +504,8 @@ Two test layers, **445 unit tests + 12 e2e fixtures + 3 `check` exit-code scenar
423
504
  | `wrappers/barrel` | `export * from "./tracker"` re-exports |
424
505
  | `wrappers/default-export` | `export default function trackFeature` propagation |
425
506
  | `wrappers/path-aliases` | `tsconfig.json` `paths` mapping (`@app/*`) |
507
+ | `sinks/presets` | PostHog, Segment, Amplitude and GA4 side by side, including `gtag("config")` that must not count |
508
+ | `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
509
  | `watch-incremental` | Engine state across sequential file updates (matches `sync` output) |
427
510
 
428
511
  **`eventra check` exit-code scenarios:**
@@ -447,4 +530,4 @@ pnpm --filter @eventra_dev/eventra-cli test:e2e # fixtures + check exit codes
447
530
 
448
531
  ## License
449
532
 
450
- MIT
533
+ 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) {
@@ -7,18 +7,27 @@ function isSymbol(value) {
7
7
  return typeof value.getDeclarations === "function";
8
8
  }
9
9
  class WrapperRegistry {
10
- checker;
10
+ pendingChecker;
11
11
  exportCache;
12
12
  wrappers = new WeakMap();
13
- constructor(checker, exportCache) {
14
- this.checker = checker;
13
+ resolvedChecker;
14
+ /** `checker` may be a provider, called once on first use (setChecker replaces it). */
15
+ constructor(pendingChecker, exportCache) {
16
+ this.pendingChecker = pendingChecker;
15
17
  this.exportCache = exportCache;
16
18
  }
17
19
  setChecker(checker) {
18
- this.checker = checker;
20
+ this.resolvedChecker = checker;
19
21
  }
20
22
  getChecker() {
21
- return this.checker;
23
+ if (!this.resolvedChecker) {
24
+ this.resolvedChecker =
25
+ typeof this.pendingChecker === "function" ? this.pendingChecker() : this.pendingChecker;
26
+ }
27
+ return this.resolvedChecker;
28
+ }
29
+ get checker() {
30
+ return this.getChecker();
22
31
  }
23
32
  normalize(symbol) {
24
33
  return ((0, exportResolver_1.resolveExportedSymbol)(symbol, this.checker, this.exportCache) ?? symbol);
package/dist/cli/check.js CHANGED
@@ -8,30 +8,41 @@ const chalk_1 = __importDefault(require("chalk"));
8
8
  const config_1 = require("../config/config");
9
9
  const projectScanner_1 = require("../core/projectScanner");
10
10
  const scanResults_1 = require("../core/scanResults");
11
+ const skipped_1 = require("../core/skipped");
11
12
  function diffSets(found, known) {
12
13
  return {
13
14
  added: [...found].filter((e) => !known.has(e)),
14
15
  removed: [...known].filter((e) => !found.has(e)),
15
16
  };
16
17
  }
17
- async function check({ fix = false }) {
18
- const config = await (0, config_1.loadConfig)();
19
- if (!config) {
20
- console.log(chalk_1.default.red("eventra.json not found"));
21
- process.exit(1);
22
- }
18
+ async function check({ fix = false, strict = false } = {}) {
19
+ const config = await (0, config_1.requireConfig)();
20
+ if (!config)
21
+ return;
23
22
  console.log(chalk_1.default.blue("Checking events and function wrappers..."));
24
- const { engine, plugins } = await (0, projectScanner_1.scanProject)(config);
23
+ const { engine, plugins, skipped, ignored } = await (0, projectScanner_1.scanProject)(config);
25
24
  await plugins.runDynamicEventReporters(engine.getAllDynamicOccurrences());
25
+ const partial = skipped.length > 0;
26
26
  const foundEvents = new Set(engine.getAllEvents());
27
27
  const knownEvents = new Set(config.events ?? []);
28
28
  const eventDiff = diffSets(foundEvents, knownEvents);
29
29
  const foundWrappers = new Set(engine.getAllFunctionWrappers());
30
30
  const knownWrappers = new Set(config.functionWrappers?.map((w) => w.name) ?? []);
31
31
  const wrapperDiff = diffSets(foundWrappers, knownWrappers);
32
+ // With skipped files the scan is incomplete: a known event that was not found
33
+ // may simply live in a skipped file, so it is not reported as removed.
34
+ const unverified = partial ? eventDiff.removed.length + wrapperDiff.removed.length : 0;
35
+ if (partial) {
36
+ eventDiff.removed = [];
37
+ wrapperDiff.removed = [];
38
+ }
32
39
  if (fix) {
33
- await (0, config_1.saveConfig)((0, scanResults_1.buildConfigFromScan)(config, engine));
40
+ await (0, config_1.saveConfig)((0, scanResults_1.buildConfigFromScan)(config, engine, { preserveKnown: partial }));
34
41
  console.log(chalk_1.default.green("Synced"));
42
+ (0, skipped_1.reportSkipped)(skipped);
43
+ (0, skipped_1.reportIgnored)(ignored);
44
+ if (strict && partial)
45
+ process.exitCode = 1;
35
46
  return;
36
47
  }
37
48
  let hasDiff = false;
@@ -47,7 +58,12 @@ async function check({ fix = false }) {
47
58
  wrapperDiff.added.forEach((w) => console.log(chalk_1.default.red(`+ ${w}`)));
48
59
  wrapperDiff.removed.forEach((w) => console.log(chalk_1.default.yellow(`- ${w}`)));
49
60
  }
50
- if (hasDiff) {
61
+ (0, skipped_1.reportSkipped)(skipped);
62
+ (0, skipped_1.reportIgnored)(ignored);
63
+ if (unverified > 0) {
64
+ console.error(chalk_1.default.gray(`${(0, skipped_1.plural)(unverified, "known entry", "known entries")} could not be verified because files were skipped.`));
65
+ }
66
+ if (hasDiff || (strict && partial)) {
51
67
  process.exit(1);
52
68
  }
53
69
  console.log(chalk_1.default.green("All good"));