@eventra_dev/eventra-cli 2.0.7 → 2.0.9

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,7 +8,7 @@
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-403%20passing-brightgreen?style=flat-square&logo=vitest&logoColor=white">
11
+ <img alt="unit tests" src="https://img.shields.io/badge/unit-445%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
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">
@@ -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`) 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`, Angular's `.html` templates) require an optional [plugin](#plugins).
28
28
 
29
29
  ---
30
30
 
@@ -272,6 +272,31 @@ The plugin:
272
272
 
273
273
  `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.
274
274
 
275
+ ### Angular (`.html`, via `templateUrl`)
276
+
277
+ ```bash
278
+ pnpm add -D @eventra_dev/cli-plugin-angular
279
+ ```
280
+
281
+ ```json
282
+ {
283
+ "plugins": ["@eventra_dev/cli-plugin-angular"],
284
+ "sync": {
285
+ "include": ["**/*.{ts,tsx,js,jsx}"],
286
+ "exclude": ["node_modules", "dist", ".angular", ".git"]
287
+ }
288
+ }
289
+ ```
290
+
291
+ The plugin:
292
+
293
+ - parses each component template with the real Angular compiler (`@angular/compiler`) into a virtual TypeScript module, paired with its component class (`foo.component.html` ↔ `foo.component.ts`, Angular CLI's default naming)
294
+ - extracts `track()` calls from the component class exactly like a regular `.ts` file (direct calls, wrappers, propagation) - no plugin needed for that part
295
+ - detects `event="feature_name"` attributes on any tag, including `*ngIf`/`*ngFor` and the `@if`/`@for`/`@switch` block syntax - both literal and dynamic; a dynamic binding resolves against the component instance (`this.field`, including getters), or surfaces as a dynamic occurrence if unresolvable
296
+ - use `[attr.event]="expr"`, not `[event]="expr"`, for a dynamic binding on a plain HTML element - `[event]` is a property binding that Angular's real compiler rejects (`NG8002`) unless the element is a component that actually declares `@Input() event`; both forms are detected identically by the plugin, only `[attr.event]` is guaranteed to compile
297
+
298
+ `sync.include` does **not** need `**/*.html` manually. Only `templateUrl`-based components are covered - an inline `template: "..."` isn't yet. Requires Node `^20.19.0 || ^22.12.0 || >=24.0.0` (higher than the other plugins' `>=18` floor - `@angular/compiler`'s own requirement). See [`@eventra_dev/cli-plugin-angular`](https://www.npmjs.com/package/@eventra_dev/cli-plugin-angular) for details.
299
+
275
300
  ### Before publishing / local development
276
301
 
277
302
  Plugins are resolved from your project's `node_modules` (same as any dependency):
@@ -357,7 +382,7 @@ No runtime execution. No monkey-patching.
357
382
 
358
383
  ## Test Coverage
359
384
 
360
- Two test layers, **409 unit tests + 12 e2e fixtures + 3 `check` exit-code scenarios + 1 `watch` scenario**.
385
+ Two test layers, **445 unit tests + 12 e2e fixtures + 3 `check` exit-code scenarios + 1 `watch` scenario**.
361
386
 
362
387
  **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.
363
388
 
@@ -24,7 +24,7 @@ function extractEvents(index, checker, _config, evaluationCache, exportCache, re
24
24
  }
25
25
  }
26
26
  if (resolved.dynamic) {
27
- (0, dynamicOccurrence_1.recordDynamicOccurrence)(call.node, resolved.values, dynamicOccurrences);
27
+ (0, dynamicOccurrence_1.recordDynamicOccurrence)(call.node, call.calleeText, resolved.values, dynamicOccurrences);
28
28
  }
29
29
  }
30
30
  }
@@ -68,7 +68,10 @@ function extractPropagationEvents(call, checker, wrapperRegistry, resolvedCallCa
68
68
  }
69
69
  }
70
70
  function extractPropagation(propagation, call, checker, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache, events, dynamicOccurrences) {
71
- const argument = call.arguments[propagation.sourceParameterIndex];
71
+ // Call site can omit the argument and rely on the parameter's own default
72
+ // value (`function f(event = "x") {}` called as `f()`) - that default is a
73
+ // real static literal, so fall back to it instead of dropping the call site.
74
+ const argument = call.arguments[propagation.sourceParameterIndex] ?? propagation.sourceParameter.initializer;
72
75
  if (!argument) {
73
76
  return;
74
77
  }
@@ -116,6 +119,6 @@ function extractPropagation(propagation, call, checker, evaluationCache, resolve
116
119
  }
117
120
  }
118
121
  if (resolved.dynamic) {
119
- (0, dynamicOccurrence_1.recordDynamicOccurrence)(call, resolved.values, dynamicOccurrences);
122
+ (0, dynamicOccurrence_1.recordDynamicOccurrence)(call, call.expression.getText(), resolved.values, dynamicOccurrences);
120
123
  }
121
124
  }
@@ -71,6 +71,54 @@ function resolveBoundObjectNode(rawNode, checker, context) {
71
71
  }
72
72
  return null;
73
73
  }
74
+ // Walks up from a `this` reference to the class it's lexically inside of, so
75
+ // `this.EVENT_NAME` can be resolved against that class's own property
76
+ // declarations - the same idea as resolving a plain identifier against its
77
+ // variable declaration, just rooted at the enclosing class instead of a
78
+ // module-scope symbol (`this` itself has no symbol/declaration of its own to
79
+ // look up via the checker).
80
+ function findEnclosingClass(node) {
81
+ let current = node;
82
+ while (current) {
83
+ if (typescript_1.default.isClassDeclaration(current) || typescript_1.default.isClassExpression(current)) {
84
+ return current;
85
+ }
86
+ current = current.parent;
87
+ }
88
+ return null;
89
+ }
90
+ function isFunctionLikeBoundary(node) {
91
+ return (typescript_1.default.isFunctionDeclaration(node) ||
92
+ typescript_1.default.isFunctionExpression(node) ||
93
+ typescript_1.default.isArrowFunction(node) ||
94
+ typescript_1.default.isMethodDeclaration(node) ||
95
+ typescript_1.default.isGetAccessorDeclaration(node) ||
96
+ typescript_1.default.isSetAccessorDeclaration(node));
97
+ }
98
+ // Collects every `return <expr>;` belonging directly to `getter` - i.e. not
99
+ // one that belongs to a function/method/accessor nested inside its body.
100
+ // A getter with conditional returns is legitimately multi-valued (which
101
+ // branch runs depends on runtime state), so the caller unions more than one
102
+ // of these the same way it already does for a reassigned `let`.
103
+ function collectReturnExpressions(getter) {
104
+ const expressions = [];
105
+ function visit(node) {
106
+ if (node !== getter && isFunctionLikeBoundary(node)) {
107
+ return;
108
+ }
109
+ if (typescript_1.default.isReturnStatement(node) && node.expression) {
110
+ expressions.push(node.expression);
111
+ }
112
+ typescript_1.default.forEachChild(node, visit);
113
+ }
114
+ if (getter.body) {
115
+ visit(getter.body);
116
+ }
117
+ return expressions;
118
+ }
119
+ function getMemberName(member) {
120
+ return typescript_1.default.isIdentifier(member.name) ? member.name.text : typescript_1.default.isStringLiteral(member.name) ? member.name.text : null;
121
+ }
74
122
  function resolvePropertyAccess(node, checker, context, visited, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache) {
75
123
  const propertyName = (0, utils_1.getPropertyName)(node);
76
124
  if (!propertyName) {
@@ -96,6 +144,40 @@ function resolvePropertyAccess(node, checker, context, visited, evaluationCache,
96
144
  }
97
145
  }
98
146
  }
147
+ // this.EVENT_NAME
148
+ // class Foo { EVENT_NAME = "checkout"; bar() { track(this.EVENT_NAME); } }
149
+ //
150
+ // this.computedEventName (getter)
151
+ // class Foo { get computedEventName() { return "checkout"; } }
152
+ if (targetExpression.kind === typescript_1.default.SyntaxKind.ThisKeyword) {
153
+ const enclosingClass = findEnclosingClass(node);
154
+ if (enclosingClass) {
155
+ for (const member of enclosingClass.members) {
156
+ if (typescript_1.default.isPropertyDeclaration(member) && member.initializer && getMemberName(member) === propertyName) {
157
+ return resolveNodeValue(member.initializer, checker, context, visited, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache);
158
+ }
159
+ if (typescript_1.default.isGetAccessorDeclaration(member) && getMemberName(member) === propertyName) {
160
+ const returnExpressions = collectReturnExpressions(member);
161
+ if (returnExpressions.length === 0) {
162
+ return empty();
163
+ }
164
+ if (returnExpressions.length === 1) {
165
+ return resolveNodeValue(returnExpressions[0], checker, context, visited, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache);
166
+ }
167
+ const values = [];
168
+ for (const expression of returnExpressions) {
169
+ const resolved = resolveNodeValue(expression, checker, context, visited, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache);
170
+ values.push(...resolved.values);
171
+ }
172
+ return {
173
+ values: unique(values),
174
+ dynamic: true,
175
+ };
176
+ }
177
+ }
178
+ }
179
+ return empty();
180
+ }
99
181
  if (typescript_1.default.isIdentifier(targetExpression)) {
100
182
  let symbol = checker.getSymbolAtLocation(targetExpression);
101
183
  if (symbol) {
@@ -157,6 +239,63 @@ function resolvePropertyAccess(node, checker, context, visited, evaluationCache,
157
239
  }
158
240
  return empty();
159
241
  }
242
+ function isConstDeclaration(declaration) {
243
+ return (typescript_1.default.getCombinedNodeFlags(declaration) & typescript_1.default.NodeFlags.Const) !== 0;
244
+ }
245
+ // `ts.isAssignmentOperator` exists on the compiled module at runtime but
246
+ // isn't part of the public .d.ts (an internal-only export), so the operator
247
+ // set is spelled out here instead of depending on an undocumented API.
248
+ const ASSIGNMENT_OPERATOR_KINDS = new Set([
249
+ typescript_1.default.SyntaxKind.EqualsToken,
250
+ typescript_1.default.SyntaxKind.PlusEqualsToken,
251
+ typescript_1.default.SyntaxKind.MinusEqualsToken,
252
+ typescript_1.default.SyntaxKind.AsteriskEqualsToken,
253
+ typescript_1.default.SyntaxKind.AsteriskAsteriskEqualsToken,
254
+ typescript_1.default.SyntaxKind.SlashEqualsToken,
255
+ typescript_1.default.SyntaxKind.PercentEqualsToken,
256
+ typescript_1.default.SyntaxKind.LessThanLessThanEqualsToken,
257
+ typescript_1.default.SyntaxKind.GreaterThanGreaterThanEqualsToken,
258
+ typescript_1.default.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken,
259
+ typescript_1.default.SyntaxKind.AmpersandEqualsToken,
260
+ typescript_1.default.SyntaxKind.BarEqualsToken,
261
+ typescript_1.default.SyntaxKind.CaretEqualsToken,
262
+ typescript_1.default.SyntaxKind.AmpersandAmpersandEqualsToken,
263
+ typescript_1.default.SyntaxKind.BarBarEqualsToken,
264
+ typescript_1.default.SyntaxKind.QuestionQuestionEqualsToken,
265
+ ]);
266
+ // A `let`/`var` binding can be reassigned after its declaration - resolving
267
+ // only the initializer would confidently report a value that a later
268
+ // assignment (still just as static - a literal, another variable, a
269
+ // template, etc.) overwrites before any sink ever sees it. This walks the
270
+ // declaring file(s) for every assignment to `symbol` so the caller can treat
271
+ // the binding as multi-valued (or at least uncertain) instead of silently
272
+ // trusting the first value it was ever given.
273
+ function collectReassignments(symbol, checker) {
274
+ const values = [];
275
+ let hasUnresolvedReassignment = false;
276
+ // See the identical `?? []` elsewhere in this file: a real ts.Symbol here
277
+ // always has a declarations array.
278
+ /* v8 ignore next */
279
+ const sourceFiles = new Set((symbol.getDeclarations() ?? []).map(declaration => declaration.getSourceFile()));
280
+ for (const sourceFile of sourceFiles) {
281
+ const visit = (node) => {
282
+ if (typescript_1.default.isBinaryExpression(node) &&
283
+ ASSIGNMENT_OPERATOR_KINDS.has(node.operatorToken.kind) &&
284
+ typescript_1.default.isIdentifier(node.left) &&
285
+ checker.getSymbolAtLocation(node.left) === symbol) {
286
+ if (node.operatorToken.kind === typescript_1.default.SyntaxKind.EqualsToken) {
287
+ values.push(node.right);
288
+ }
289
+ else {
290
+ hasUnresolvedReassignment = true;
291
+ }
292
+ }
293
+ typescript_1.default.forEachChild(node, visit);
294
+ };
295
+ visit(sourceFile);
296
+ }
297
+ return { values, hasUnresolvedReassignment };
298
+ }
160
299
  function resolveIdentifier(node, checker, context, visited, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache) {
161
300
  const boundSymbol = checker.getSymbolAtLocation(node);
162
301
  if (boundSymbol && context.parameterBindings.has(boundSymbol)) {
@@ -186,7 +325,21 @@ function resolveIdentifier(node, checker, context, visited, evaluationCache, res
186
325
  const declarations = symbol.getDeclarations() ?? [];
187
326
  for (const declaration of declarations) {
188
327
  if (typescript_1.default.isVariableDeclaration(declaration) && declaration.initializer) {
189
- const resolved = resolveNodeValue(declaration.initializer, checker, context, visited, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache);
328
+ const reassignment = isConstDeclaration(declaration) ? null : collectReassignments(symbol, checker);
329
+ if (!reassignment || (reassignment.values.length === 0 && !reassignment.hasUnresolvedReassignment)) {
330
+ const resolved = resolveNodeValue(declaration.initializer, checker, context, visited, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache);
331
+ evaluationCache.set(symbol, resolved);
332
+ return resolved;
333
+ }
334
+ const values = [];
335
+ for (const expression of [declaration.initializer, ...reassignment.values]) {
336
+ const resolvedValue = resolveNodeValue(expression, checker, context, visited, evaluationCache, resolvedCallCache, returnPropagationCache, exportCache);
337
+ values.push(...resolvedValue.values);
338
+ }
339
+ const resolved = {
340
+ values: unique(values),
341
+ dynamic: true,
342
+ };
190
343
  evaluationCache.set(symbol, resolved);
191
344
  return resolved;
192
345
  }
@@ -213,7 +366,11 @@ function resolveCallExpression(node, checker, context, visited, evaluationCache,
213
366
  }
214
367
  const values = [];
215
368
  for (const propagation of semantic.propagations) {
216
- const argument = node.arguments[propagation.parameterIndex];
369
+ // Call site can omit the argument and rely on the parameter's own default
370
+ // value (`function build(name = "x") {}` called as `build()`) - that
371
+ // default is a real static literal, so fall back to it instead of
372
+ // dropping the call site.
373
+ const argument = node.arguments[propagation.parameterIndex] ?? propagation.parameter.initializer;
217
374
  if (!argument) {
218
375
  continue;
219
376
  }
@@ -0,0 +1,58 @@
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.detectJsxEventAttribute = detectJsxEventAttribute;
7
+ const typescript_1 = __importDefault(require("typescript"));
8
+ const EVENT_ATTRIBUTE_NAME = "event";
9
+ /**
10
+ * Detects a JSX `event="literal"` / `event={expr}` attribute - the same
11
+ * declarative-tracking convention the Vue/Svelte/Astro/Angular CLI plugins
12
+ * already use for their own templates, recognized on any tag (plain
13
+ * elements and components alike). No plugin or preprocessing is needed for
14
+ * React: JSX is already part of the TypeScript AST the core walks natively,
15
+ * so this is a plain node-kind check alongside the existing CallExpression
16
+ * sink detection, not a separate virtual-module bridge.
17
+ *
18
+ * Reported through the exact same `TrackCall` shape as a real track() call:
19
+ * a literal value resolves directly, and a dynamic expression is resolved
20
+ * through the same checker-based pipeline as any other TS expression,
21
+ * falling back to a dynamic occurrence (not silently dropped) when it
22
+ * doesn't resolve to a literal.
23
+ */
24
+ function detectJsxEventAttribute(node) {
25
+ if (!typescript_1.default.isJsxAttribute(node)) {
26
+ return null;
27
+ }
28
+ if (!typescript_1.default.isIdentifier(node.name) || node.name.text !== EVENT_ATTRIBUTE_NAME) {
29
+ return null;
30
+ }
31
+ const initializer = node.initializer;
32
+ if (!initializer) {
33
+ // Boolean shorthand `<Button event />` - no name to extract, same as
34
+ // the other plugins' own boolean-shorthand `event` handling.
35
+ return null;
36
+ }
37
+ let value;
38
+ if (typescript_1.default.isStringLiteral(initializer)) {
39
+ // event="literal"
40
+ value = initializer;
41
+ }
42
+ else if (typescript_1.default.isJsxExpression(initializer)) {
43
+ // event={expr} - an empty event={} has no expression to extract.
44
+ if (!initializer.expression) {
45
+ return null;
46
+ }
47
+ value = initializer.expression;
48
+ }
49
+ else {
50
+ return null;
51
+ }
52
+ return {
53
+ node,
54
+ sourceFile: node.getSourceFile(),
55
+ trackedArguments: [value],
56
+ calleeText: node.parent.parent.tagName.getText(),
57
+ };
58
+ }
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.scanSource = scanSource;
7
7
  const typescript_1 = __importDefault(require("typescript"));
8
8
  const sinkDetector_1 = require("./sinkDetector");
9
+ const jsxEventAttribute_1 = require("./jsxEventAttribute");
9
10
  const propagationAnalyzer_1 = require("./analyzer/propagationAnalyzer");
10
11
  function scanSource(source, checker, wrapperRegistry, plugins) {
11
12
  const sinks = [];
@@ -51,9 +52,16 @@ function scanSource(source, checker, wrapperRegistry, plugins) {
51
52
  node,
52
53
  sourceFile: source,
53
54
  trackedArguments,
55
+ calleeText: node.expression.getText(source),
54
56
  });
55
57
  }
56
58
  }
59
+ // JSX `event="..."`/`event={expr}` attribute detection (React/JSX files
60
+ // only - a plain .ts/.js file never contains a JsxAttribute node).
61
+ const jsxEventAttribute = (0, jsxEventAttribute_1.detectJsxEventAttribute)(node);
62
+ if (jsxEventAttribute) {
63
+ trackCalls.push(jsxEventAttribute);
64
+ }
57
65
  typescript_1.default.forEachChild(node, visit);
58
66
  }
59
67
  visit(source);
@@ -7,15 +7,19 @@ Object.defineProperty(exports, "__esModule", { value: true });
7
7
  exports.recordDynamicOccurrence = recordDynamicOccurrence;
8
8
  const typescript_1 = __importDefault(require("typescript"));
9
9
  const CALL_TEXT_MAX_LENGTH = 120;
10
- function recordDynamicOccurrence(call, resolvedValues, out) {
11
- const sourceFile = call.getSourceFile();
12
- const { line, character } = typescript_1.default.getLineAndCharacterOfPosition(sourceFile, call.getStart(sourceFile));
13
- const callText = call.getText(sourceFile);
10
+ // `node` is a CallExpression for a real track() call, or a JsxAttribute for
11
+ // a JSX `event="..."`/`event={expr}` binding (see jsxEventAttribute.ts) -
12
+ // `calleeText` is supplied by the caller since it's derived differently per
13
+ // node kind (a call's callee text vs. a JSX element's tag name).
14
+ function recordDynamicOccurrence(node, calleeText, resolvedValues, out) {
15
+ const sourceFile = node.getSourceFile();
16
+ const { line, character } = typescript_1.default.getLineAndCharacterOfPosition(sourceFile, node.getStart(sourceFile));
17
+ const callText = node.getText(sourceFile);
14
18
  out.push({
15
19
  fileName: sourceFile.fileName,
16
20
  line: line + 1,
17
21
  character,
18
- calleeText: call.expression.getText(sourceFile),
22
+ calleeText,
19
23
  callText: callText.length > CALL_TEXT_MAX_LENGTH ? `${callText.slice(0, CALL_TEXT_MAX_LENGTH)}…` : callText,
20
24
  resolvedValues,
21
25
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventra_dev/eventra-cli",
3
- "version": "2.0.7",
3
+ "version": "2.0.9",
4
4
  "description": "Static analytics event extraction CLI powered by the TypeScript compiler API",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",