@eventra_dev/eventra-cli 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,9 +8,9 @@
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-110%20passing-brightgreen?style=flat-square&logo=vitest&logoColor=white">
11
+ <img alt="unit tests" src="https://img.shields.io/badge/unit-403%20passing-brightgreen?style=flat-square&logo=vitest&logoColor=white">
12
12
  <img alt="e2e fixtures" src="https://img.shields.io/badge/e2e-12%20fixtures-brightgreen?style=flat-square">
13
- <img alt="coverage" src="https://img.shields.io/badge/coverage-73.5%25%20(unit)-yellow?style=flat-square&logo=vitest&logoColor=white">
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>
16
16
  <img alt="license" src="https://img.shields.io/npm/l/@eventra_dev/eventra-cli?style=flat-square&color=lightgrey">
@@ -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`) require an optional [plugin](#plugins).
27
+ It does **not** detect generic `track()`, Segment, Google Analytics, or other libraries out of the box. Framework files (e.g. `.vue`, `.svelte`, `.astro`) require an optional [plugin](#plugins).
28
28
 
29
29
  ---
30
30
 
@@ -228,6 +228,46 @@ The plugin:
228
228
 
229
229
  `sync.include` does **not** need `**/*.vue` manually — the plugin registers `**/*.vue` via `includeGlobs`. See [`@eventra_dev/cli-plugin-vue`](https://www.npmjs.com/package/@eventra_dev/cli-plugin-vue) for details.
230
230
 
231
+ ### Svelte (`.svelte`)
232
+
233
+ ```bash
234
+ pnpm add -D @eventra_dev/cli-plugin-svelte
235
+ ```
236
+
237
+ ```json
238
+ {
239
+ "plugins": ["@eventra_dev/cli-plugin-svelte"]
240
+ }
241
+ ```
242
+
243
+ The plugin:
244
+
245
+ - parses each `.svelte` file with the real Svelte compiler (`svelte/compiler`) into a single virtual TypeScript module combining `context="module"` and instance `<script>` blocks
246
+ - extracts `track()` calls from the script exactly like a regular `.ts` file (direct calls, wrappers, propagation); Svelte 5 runes (`$props`, `$state`, …) pass through untouched
247
+ - detects `event="feature_name"` attributes on any tag — plain elements, components, `{#if}`/`{#each}`/`{#await}`/`{#key}` blocks, slots — both literal and dynamic (`event={expr}`); dynamic bindings resolve through the same module scope as the script, or surface as a dynamic occurrence if unresolvable
248
+
249
+ `sync.include` does **not** need `**/*.svelte` manually. See [`@eventra_dev/cli-plugin-svelte`](https://www.npmjs.com/package/@eventra_dev/cli-plugin-svelte) for details.
250
+
251
+ ### Astro (`.astro`)
252
+
253
+ ```bash
254
+ pnpm add -D @eventra_dev/cli-plugin-astro
255
+ ```
256
+
257
+ ```json
258
+ {
259
+ "plugins": ["@eventra_dev/cli-plugin-astro"]
260
+ }
261
+ ```
262
+
263
+ The plugin:
264
+
265
+ - parses each `.astro` file with the real Astro compiler (`@astrojs/compiler`) into a single virtual TypeScript module from the frontmatter
266
+ - extracts `track()` calls from the frontmatter exactly like a regular `.ts` file (direct calls, wrappers, propagation); `Astro.props` and other globals pass through untouched
267
+ - detects `event="feature_name"` attributes on any tag, including inside JSX expressions (`{cond && <Button event="..." />}`, `.map()`) — both literal and dynamic (`event={expr}`, including the `{event}` shorthand); dynamic bindings resolve through the same module scope as the frontmatter, or surface as a dynamic occurrence if unresolvable
268
+
269
+ `sync.include` does **not** need `**/*.astro` manually. See [`@eventra_dev/cli-plugin-astro`](https://www.npmjs.com/package/@eventra_dev/cli-plugin-astro) for details.
270
+
231
271
  ### Before publishing / local development
232
272
 
233
273
  Plugins are resolved from your project's `node_modules` (same as any dependency):
@@ -254,7 +294,7 @@ External plugins export an object (or factory) with:
254
294
  | `transform({ path, source })` | Returns `{ modules: [{ path, content }] }` |
255
295
  | `staticSinks?` | Declarative callee-based sink rules (CLI converts to internal detectors) |
256
296
 
257
- No dependency on `@eventra_dev/eventra-cli` is required. See [`ARCHITECTURE.md`](./ARCHITECTURE.md#plugin-kernel-phase-15--foundation-shipped) for details.
297
+ No dependency on `@eventra_dev/eventra-cli` is required — the host CLI loads the plugin, converts `staticSinks` into its own internal sink detectors, and calls `transform()` on matching files; the plugin never imports anything from the CLI itself.
258
298
 
259
299
  **Only official `@eventra_dev/cli-plugin-*` packages can be loaded.** `eventra.json` is meant to be committed to git, so its `plugins` array is effectively PR-editable; since the CLI `import()`s whatever is listed there, an arbitrary specifier would be arbitrary code execution on every machine that runs `sync`/`check`/`watch`. Because `@eventra_dev` is an npm scope only the Eventra maintainers can publish to, restricting to `@eventra_dev/cli-plugin-*` means nothing outside that scope can ever be loaded this way. Any other entry in `plugins` is skipped with a console warning instead of being imported — third-party/community plugins aren't supported today.
260
300
 
@@ -313,11 +353,11 @@ No runtime execution. No monkey-patching.
313
353
 
314
354
  ## Test Coverage
315
355
 
316
- Two test layers, **110 unit tests + 12 e2e fixtures + 3 `check` exit-code scenarios + 1 `watch` scenario**.
356
+ Two test layers, **409 unit tests + 12 e2e fixtures + 3 `check` exit-code scenarios + 1 `watch` scenario**.
317
357
 
318
- **73.5% statement coverage** (58.1% branch, 83.1% function, 73.8% line — v8 provider, `pnpm test:coverage`). 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.
358
+ **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.
319
359
 
320
- **Unit tests (vitest)** — 21 suites covering core modules:
360
+ **Unit tests (vitest)** — 39 suites covering core modules:
321
361
 
322
362
  | Module | Covers |
323
363
  |---|---|
@@ -329,7 +369,7 @@ Two test layers, **110 unit tests + 12 e2e fixtures + 3 `check` exit-code scenar
329
369
  | `PluginRegistry` | Built-in SDK sink, preprocessors, virtual-path mapping, include-pattern dedup |
330
370
  | `external plugin adapter` | Transform output mapping, static sink registration, invalid result rejection |
331
371
  | `external plugin dynamic sink` | A non-literal argument at a plugin-declared static sink surfaces as a dynamic occurrence instead of being silently dropped |
332
- | `vue-shaped external plugin` | Adapter path for script + template virtual modules and `staticSinks` |
372
+ | `vue-shaped`/`svelte-shaped`/`astro-shaped` external plugin | Adapter path for script + template virtual modules and `staticSinks`, one suite per framework |
333
373
  | `processFile` | Script-kind detection, import/export specifier extraction |
334
374
  | `extractTemplateExpressions` | Vue/Svelte/Astro attribute patterns |
335
375
  | `config` | `normalizeConfig` (sort events, dedupe wrappers, defaults, preserve `apiKey`/`endpoint`/`sync`/`plugins`); `resolveApiKey` env-var > `eventra.local.json` > legacy-inline priority, `saveLocalApiKey`'s gitignore-append |
@@ -338,7 +378,7 @@ Two test layers, **110 unit tests + 12 e2e fixtures + 3 `check` exit-code scenar
338
378
  | `cross-file SDK detection` | Direct `.track()` and wrapper calls resolve across files even when the call-site file never imports the SDK package itself |
339
379
  | `dynamic event reporter` | Dynamic vs. static call classification, end-to-end `registerDynamicEventReporter` invocation |
340
380
  | `load plugins` | Trusted-specifier allowlist (`^@eventra_dev/cli-plugin-[a-z0-9-]+$`), accepted/rejected plugin shapes |
341
- | `Nuxt auto-import compatibility` | Ambient `declare const x: typeof import(...)` shape resolution — a hand-written fixture, not a real `nuxt build` (see `packages/cli-plugin-vue`'s README) |
381
+ | `Nuxt auto-import compatibility` | Ambient `declare const x: typeof import(...)` shape resolution — a hand-written fixture, not a real `nuxt build` |
342
382
  | `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) |
343
383
  | `send()` | Endpoint trust-on-first-use (TOFU): default-trusted, blocked-until-approved, approve-then-persists, re-block-on-change, env-var-bypasses-approval |
344
384
  | `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 |
@@ -45,6 +45,12 @@ function extractPropagationEvents(call, checker, wrapperRegistry, resolvedCallCa
45
45
  return;
46
46
  }
47
47
  const signature = checker.getSignatureFromDeclaration(resolved);
48
+ // resolveFunctionFromCall only ever returns a FunctionDeclaration,
49
+ // MethodDeclaration, ArrowFunction, or FunctionExpression (verified: every
50
+ // one of those kinds — including a bodyless ambient overload signature —
51
+ // yields a signature from getSignatureFromDeclaration), so this guard has
52
+ // no reachable failure case; it's defensive only.
53
+ /* v8 ignore if */
48
54
  if (!signature) {
49
55
  return;
50
56
  }
@@ -74,6 +80,11 @@ function extractPropagation(propagation, call, checker, evaluationCache, resolve
74
80
  // propertyPath by evaluating targetNode with `payload` substituted for
75
81
  // the *raw*, un-narrowed call-site argument.
76
82
  const parameterSymbol = checker.getSymbolAtLocation(propagation.sourceParameter.name);
83
+ // Every real ts.ParameterDeclaration identifier is bound to a symbol by
84
+ // the TS binder (verified across ordinary functions, type-only function
85
+ // types, interface call signatures, and ambient declarations), so
86
+ // `parameterSymbol` is never undefined here; the guard is defensive only.
87
+ /* v8 ignore else */
77
88
  if (parameterSymbol) {
78
89
  context.parameterBindings.set(parameterSymbol, argument);
79
90
  }
@@ -34,6 +34,12 @@ function getExpressionSymbol(expression, checker) {
34
34
  return checker.getSymbolAtLocation(expression.name);
35
35
  }
36
36
  // analytics?.track()
37
+ // A PropertyAccessChain has the exact same SyntaxKind (PropertyAccessExpression)
38
+ // as a plain property access - the "chain" distinction is purely a flag
39
+ // (NodeFlags.OptionalChain) on the same node kind, not a separate kind. The
40
+ // isPropertyAccessExpression check above already matches (and returns for)
41
+ // every property-access-chain node, so this branch can never be reached.
42
+ /* v8 ignore next 3 */
37
43
  if (typescript_1.default.isPropertyAccessChain(expression)) {
38
44
  return checker.getSymbolAtLocation(expression.name);
39
45
  }
@@ -60,7 +60,17 @@ function resolveExportedSymbol(symbol, checker, cache, visited) {
60
60
  if (cached !== undefined) {
61
61
  return cached;
62
62
  }
63
- // cycle guard
63
+ // cycle guard - defends against a cycle re-entering this function via the
64
+ // alias/export/ambient recursive calls below. In practice, a genuinely
65
+ // circular alias chain (e.g. two barrel files re-exporting each other's
66
+ // binding) is collapsed by the TypeScript checker itself: getAliasedSymbol
67
+ // returns its own synthetic "unknown" symbol after a single hop rather than
68
+ // looping, and getExportSymbolOfSymbol/the ambient-auto-import unwrap were
69
+ // not observed (across many realistic export shapes: named/default/star/
70
+ // namespace-as/CJS `export =`/ambient-module/UMD re-exports) to ever
71
+ // redirect to an already-visited symbol either. We could not construct a
72
+ // realistic source file that re-enters with the same symbol still `seen`.
73
+ /* v8 ignore next 4 */
64
74
  if (seen.has(symbol)) {
65
75
  cache.set(symbol, null);
66
76
  return null;
@@ -73,8 +83,16 @@ function resolveExportedSymbol(symbol, checker, cache, visited) {
73
83
  cache.set(symbol, resolved);
74
84
  return resolved;
75
85
  }
76
- // export unwrap
86
+ // export unwrap - TypeScript's binder can in principle give a declaration a
87
+ // distinct "local" symbol and "export" symbol (see `declareModuleMember`'s
88
+ // `local.exportSymbol` link), but this was not observed via
89
+ // checker.getSymbolAtLocation for any realistic export shape we tried
90
+ // (named/default/star/namespace-as/CJS `export =`/ambient-module/UMD
91
+ // exports, from both the declaring file and importing files) - the split
92
+ // only seems to materialize for binder-internal container shapes we could
93
+ // not reach from ordinary source text.
77
94
  const exportSymbol = checker.getExportSymbolOfSymbol(symbol);
95
+ /* v8 ignore next 4 */
78
96
  if (exportSymbol && exportSymbol !== symbol) {
79
97
  const resolved = resolveExportedSymbol(exportSymbol, checker, cache, seen);
80
98
  cache.set(symbol, resolved);
@@ -95,8 +113,12 @@ function resolveExportedSymbol(symbol, checker, cache, visited) {
95
113
  return symbol;
96
114
  }
97
115
  }
98
- // final validation
116
+ // final validation - `.some(isConcreteDeclaration)` re-checks the exact same
117
+ // predicate over the exact same `declarations` array the for-loop above just
118
+ // iterated without finding a match; if the loop didn't return, `.some` of
119
+ // the identical predicate over the identical array is guaranteed false too.
99
120
  const valid = declarations.some(isConcreteDeclaration);
121
+ /* v8 ignore next 3 */
100
122
  if (valid) {
101
123
  cache.set(symbol, symbol);
102
124
  return symbol;
@@ -41,6 +41,11 @@ function resolveBoundObjectNode(rawNode, checker, context) {
41
41
  if (!symbol || !context.parameterBindings.has(symbol)) {
42
42
  return null;
43
43
  }
44
+ // `has(symbol)` above already guarantees a Map entry exists, and every
45
+ // caller only ever `.set()`s parameterBindings to a real ts.Expression
46
+ // (never `undefined`) - so `.get(symbol)` can't actually miss here; the
47
+ // `?? null` is defensive typing, not a reachable second outcome.
48
+ /* v8 ignore next */
44
49
  return context.parameterBindings.get(symbol) ?? null;
45
50
  }
46
51
  if (typescript_1.default.isPropertyAccessExpression(node) || typescript_1.default.isPropertyAccessChain(node) || typescript_1.default.isElementAccessExpression(node)) {
@@ -100,12 +105,22 @@ function resolvePropertyAccess(node, checker, context, visited, evaluationCache,
100
105
  // LOGIN = "login"
101
106
  // }
102
107
  if (symbol) {
108
+ // symbol comes from checker.getSymbolAtLocation (never a hand-built
109
+ // duck-typed object here), and real ts.Symbol.getDeclarations() always
110
+ // returns an array (possibly empty) rather than undefined - the `?? []`
111
+ // is defensive against the wider `Declaration[] | undefined` type, not
112
+ // a reachable second outcome for a real symbol.
113
+ /* v8 ignore next */
103
114
  const declarations = symbol.getDeclarations() ?? [];
104
115
  for (const declaration of declarations) {
105
116
  if (!typescript_1.default.isEnumDeclaration(declaration)) {
106
117
  continue;
107
118
  }
108
119
  for (const member of declaration.members) {
120
+ // An EnumMember's `name` can only be an Identifier or a
121
+ // StringLiteral per the TS grammar - the `: null` fallback has no
122
+ // third case to reach.
123
+ /* v8 ignore next */
109
124
  const memberName = typescript_1.default.isIdentifier(member.name) ? member.name.text : typescript_1.default.isStringLiteral(member.name) ? member.name.text : null;
110
125
  if (memberName !== propertyName || !member.initializer) {
111
126
  continue;
@@ -118,6 +133,9 @@ function resolvePropertyAccess(node, checker, context, visited, evaluationCache,
118
133
  // LOGIN: "login"
119
134
  // }
120
135
  if (symbol) {
136
+ // See the identical `?? []` above the enum-member loop: a real
137
+ // ts.Symbol here always has a declarations array.
138
+ /* v8 ignore next */
121
139
  const declarations = symbol.getDeclarations() ?? [];
122
140
  for (const declaration of declarations) {
123
141
  if (!typescript_1.default.isVariableDeclaration(declaration) || !declaration.initializer || !typescript_1.default.isObjectLiteralExpression(declaration.initializer)) {
@@ -143,6 +161,10 @@ function resolveIdentifier(node, checker, context, visited, evaluationCache, res
143
161
  const boundSymbol = checker.getSymbolAtLocation(node);
144
162
  if (boundSymbol && context.parameterBindings.has(boundSymbol)) {
145
163
  const bound = context.parameterBindings.get(boundSymbol);
164
+ // `has(boundSymbol)` above already guarantees a Map entry, and every
165
+ // caller only ever `.set()`s parameterBindings to a real ts.Expression
166
+ // (never `undefined`) - so this is always truthy when reached.
167
+ /* v8 ignore else */
146
168
  if (bound) {
147
169
  return resolveNodeValue(bound, checker, context, visited, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache);
148
170
  }
@@ -158,6 +180,9 @@ function resolveIdentifier(node, checker, context, visited, evaluationCache, res
158
180
  }
159
181
  const resolving = empty();
160
182
  evaluationCache.set(symbol, resolving);
183
+ // See the identical `?? []` above in resolvePropertyAccess: a real
184
+ // ts.Symbol here always has a declarations array.
185
+ /* v8 ignore next */
161
186
  const declarations = symbol.getDeclarations() ?? [];
162
187
  for (const declaration of declarations) {
163
188
  if (typescript_1.default.isVariableDeclaration(declaration) && declaration.initializer) {
@@ -195,7 +220,12 @@ function resolveCallExpression(node, checker, context, visited, evaluationCache,
195
220
  const nextContext = {
196
221
  parameterBindings: new Map(context.parameterBindings),
197
222
  };
223
+ // returnPropagationAnalyzer's resolveParameter only ever returns a
224
+ // parameter whose `.name` is a plain ts.Identifier (destructured/
225
+ // computed parameter names are filtered out there), so a real program's
226
+ // checker always resolves a symbol for it here.
198
227
  const parameterSymbol = checker.getSymbolAtLocation(propagation.parameter.name);
228
+ /* v8 ignore next 2 */
199
229
  if (parameterSymbol) {
200
230
  nextContext.parameterBindings.set(parameterSymbol, argument);
201
231
  }
@@ -18,6 +18,17 @@ function isTrackCallee(expr) {
18
18
  }
19
19
  return false;
20
20
  }
21
+ // An ImportSpecifier/ImportClause's own symbol always carries
22
+ // SymbolFlags.Alias (that's how TS's binder models every import), so
23
+ // isEventraSymbol's alias-unwrap below always recurses into
24
+ // checker.getAliasedSymbol(...) first and never falls through to inspect
25
+ // these declarations directly - getAliasedSymbol also fully resolves a
26
+ // multi-hop re-export chain in a single call (verified empirically: a
27
+ // 12-deep `export { Eventra } from "./prev"` barrel chain still resolves
28
+ // in exactly one getAliasedSymbol hop), so there's no realistic import
29
+ // shape where a symbol reaches isEventraSymbol's declaration loop while
30
+ // one of its own declarations is still an ImportSpecifier/ImportClause.
31
+ /* v8 ignore start */
21
32
  function getImportDeclarationForSpecifier(decl) {
22
33
  let current = decl;
23
34
  while (current) {
@@ -43,6 +54,7 @@ function isEventraImportDeclaration(decl) {
43
54
  }
44
55
  return false;
45
56
  }
57
+ /* v8 ignore stop */
46
58
  function isEventraClassDeclaration(decl) {
47
59
  if (!typescript_1.default.isClassDeclaration(decl) || decl.name?.text !== "Eventra") {
48
60
  return false;
@@ -51,12 +63,21 @@ function isEventraClassDeclaration(decl) {
51
63
  return file.includes("eventra-sdk") || file.includes("@eventra_dev");
52
64
  }
53
65
  function isEventraSymbol(symbol, checker, depth = 0) {
66
+ // cycle guard - the only recursive call below is the alias-unwrap, and
67
+ // checker.getAliasedSymbol always resolves a whole (even multi-hop)
68
+ // alias/re-export chain in a single call rather than one hop at a time
69
+ // (verified empirically), so depth realistically never exceeds 1.
70
+ /* v8 ignore next 3 */
54
71
  if (depth > 10) {
55
72
  return false;
56
73
  }
57
74
  if (symbol.flags & typescript_1.default.SymbolFlags.Alias) {
58
75
  return isEventraSymbol(checker.getAliasedSymbol(symbol), checker, depth + 1);
59
76
  }
77
+ // A real ts.Symbol.getDeclarations() always returns an array (possibly
78
+ // empty), never undefined - the `?? []` is defensive against the wider
79
+ // `Declaration[] | undefined` type, not a reachable second outcome.
80
+ /* v8 ignore next */
60
81
  for (const decl of symbol.getDeclarations() ?? []) {
61
82
  if (isEventraImportDeclaration(decl) || isEventraClassDeclaration(decl)) {
62
83
  return true;
@@ -73,6 +94,11 @@ function isEventraNewExpression(node, checker) {
73
94
  return symbol ? isEventraSymbol(symbol, checker) : false;
74
95
  }
75
96
  function typeLooksLikeEventra(type, checker, depth = 0) {
97
+ // cycle guard - TypeScript flattens union/intersection types (a literal
98
+ // `A | (B | C)` normalizes to the single-level `A | B | C`), so the only
99
+ // recursive call below in practice never nests deep enough to approach
100
+ // this bound.
101
+ /* v8 ignore next 3 */
76
102
  if (depth > 8) {
77
103
  return false;
78
104
  }
@@ -105,6 +131,9 @@ function isEventraReceiverExpression(receiver, checker) {
105
131
  if (!symbol) {
106
132
  return false;
107
133
  }
134
+ // See the identical `?? []` above isEventraSymbol's declaration loop: a
135
+ // real ts.Symbol here always has a declarations array.
136
+ /* v8 ignore next */
108
137
  for (const decl of symbol.getDeclarations() ?? []) {
109
138
  if (typescript_1.default.isVariableDeclaration(decl) && decl.initializer) {
110
139
  if (typescript_1.default.isNewExpression(decl.initializer) && isEventraNewExpression(decl.initializer, checker)) {
@@ -122,6 +151,13 @@ function isEventraReceiverExpression(receiver, checker) {
122
151
  return true;
123
152
  }
124
153
  }
154
+ // A class field can only ever be reached through a qualified access
155
+ // (`this.field`/`instance.field`), which recurses through the
156
+ // PropertyAccessExpression branch below instead of ever landing here -
157
+ // a *bare* identifier's resolved symbol can't have a PropertyDeclaration
158
+ // among its own declarations, so this is unreachable from the
159
+ // `ts.isIdentifier(receiver)` branch above.
160
+ /* v8 ignore next 5 */
125
161
  if (typescript_1.default.isPropertyDeclaration(decl) && decl.type) {
126
162
  const propType = checker.getTypeFromTypeNode(decl.type);
127
163
  if (typeLooksLikeEventra(propType, checker)) {
@@ -141,11 +177,16 @@ function isEventraSdkTrackCall(call, checker) {
141
177
  if (!isTrackCallee(expr)) {
142
178
  return false;
143
179
  }
180
+ // isTrackCallee above only returns true for these same three shapes, so
181
+ // the `: undefined` side of this ternary is never actually taken.
182
+ /* v8 ignore next 5 */
144
183
  const receiver = typescript_1.default.isPropertyAccessExpression(expr) ||
145
184
  typescript_1.default.isPropertyAccessChain(expr) ||
146
185
  typescript_1.default.isElementAccessExpression(expr)
147
186
  ? expr.expression
148
187
  : undefined;
188
+ // ...and so `receiver` is never actually undefined here either.
189
+ /* v8 ignore next 3 */
149
190
  if (!receiver) {
150
191
  return false;
151
192
  }
@@ -43,14 +43,12 @@ function getCallName(expression) {
43
43
  }
44
44
  //
45
45
  // analytics.track()
46
- //
47
- if (typescript_1.default.isPropertyAccessExpression(expression)) {
48
- return expression.name.text;
49
- }
50
- //
51
46
  // analytics?.track()
52
47
  //
53
- if (typescript_1.default.isPropertyAccessChain(expression)) {
48
+ // ts.isPropertyAccessExpression is also true for a PropertyAccessChain
49
+ // (optional-chain access is the same SyntaxKind with questionDotToken set),
50
+ // so there is no separate isPropertyAccessChain case to handle here.
51
+ if (typescript_1.default.isPropertyAccessExpression(expression)) {
54
52
  return expression.name.text;
55
53
  }
56
54
  return "";
package/dist/cli/send.js CHANGED
@@ -28,6 +28,12 @@ function backoffMs(attempt) {
28
28
  // single delivery attempt with abort timeout
29
29
  async function attemptDelivery(endpoint, apiKey, payload) {
30
30
  const controller = new AbortController();
31
+ // Exercising this callback for real means either a genuine 10s wait per
32
+ // attempt (too slow for a unit test, especially across MAX_ATTEMPTS
33
+ // retries) or faking timers through an AbortController+fetch+retry-loop
34
+ // chain, which deadlocks vi.runAllTimersAsync/advanceTimersByTimeAsync in
35
+ // practice - not worth the added flakiness for one timeout wire-up line.
36
+ /* v8 ignore next */
31
37
  const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
32
38
  try {
33
39
  const res = await fetch(endpoint, {
@@ -89,9 +89,11 @@ class CompilerContext {
89
89
  return fromRegistry;
90
90
  }
91
91
  const fromDisk = originalGetSourceFile(normalized, languageVersionOrOptions, onError, shouldCreateNewSourceFile);
92
+ /* v8 ignore if -- unreachable: host.readFile (below) only ever returns a string or throws (fs.readFileSync semantics); ts's default getSourceFile only yields undefined when readFile resolves to undefined without throwing, which this host can never produce */
92
93
  if (!fromDisk) {
93
94
  return undefined;
94
95
  }
96
+ /* v8 ignore else -- unreachable: reaching this line already required an undefined snapshot above, and DocumentRecord always carries a snapshot once registry.has() is true, so has() cannot have flipped true in between */
95
97
  if (!this.registry.has(normalized)) {
96
98
  this.registry.update(normalized, fromDisk.getFullText());
97
99
  }
@@ -70,6 +70,11 @@ function pluginLoadHint(spec) {
70
70
  }
71
71
  async function loadExternalPlugin(registry, spec) {
72
72
  const trimmed = spec.trim();
73
+ // Defensive-only: the sole caller (createPluginRegistry) already does
74
+ // `if (!trimmed) continue;` before ever calling this function, so `spec`
75
+ // here is always non-blank. Kept as a guard in case a future caller is
76
+ // added that doesn't pre-filter.
77
+ /* v8 ignore next */
73
78
  if (!trimmed)
74
79
  return;
75
80
  let mod;
@@ -77,6 +82,12 @@ async function loadExternalPlugin(registry, spec) {
77
82
  mod = await Promise.resolve(`${trimmed}`).then(s => __importStar(require(s)));
78
83
  }
79
84
  catch (err) {
85
+ // Node's dynamic import() only ever rejects with an Error (module
86
+ // resolution, syntax, or evaluation errors are all Error instances) —
87
+ // the String(err) fallback can't be reached without a plugin module
88
+ // whose top-level code does a bare `throw "non-error value"`, which
89
+ // we won't fabricate as a persistent test fixture. Defensive-only.
90
+ /* v8 ignore next */
80
91
  const message = err instanceof Error ? err.message : String(err);
81
92
  throw new Error(`Failed to load plugin "${trimmed}": ${message}.${pluginLoadHint(trimmed)}`);
82
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventra_dev/eventra-cli",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
4
4
  "description": "Static analytics event extraction CLI powered by the TypeScript compiler API",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",