@eventra_dev/eventra-cli 2.0.6 → 2.0.8
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 +4 -0
- package/dist/analysis/extractor/extractor.js +1 -1
- package/dist/analysis/extractor/propagationExtractor.js +5 -2
- package/dist/analysis/resolver/resolver.js +159 -2
- package/dist/analysis/scanner/jsxEventAttribute.js +58 -0
- package/dist/analysis/scanner/scanner.js +8 -0
- package/dist/analysis/shared/dynamicOccurrence.js +9 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -131,6 +131,8 @@ trackFeature("purchase");
|
|
|
131
131
|
|
|
132
132
|
The second argument (`userId`, `properties`) is **not** used as the event name.
|
|
133
133
|
|
|
134
|
+
A literal name that violates either rule (too long, or a character outside the allowed set) is not truncated and not flagged as a dynamic occurrence - it's silently excluded from the scan result, with no warning and no diagnostic anywhere. It simply never appears in `eventra.json` after `sync`. If an event you expect to see is missing, check this before assuming a detection bug.
|
|
135
|
+
|
|
134
136
|
---
|
|
135
137
|
|
|
136
138
|
## What is ignored
|
|
@@ -188,6 +190,8 @@ eventra send --trust-endpoint
|
|
|
188
190
|
|
|
189
191
|
This records the endpoint in `eventra.local.json` (gitignored). If `eventra.json`'s `endpoint` later changes to a different value — e.g. a PR silently pointing it at another host — `send` blocks again until re-approved, so a committed config change alone can't redirect where your API key and events get sent.
|
|
190
192
|
|
|
193
|
+
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
|
+
|
|
191
195
|
Network resilience:
|
|
192
196
|
|
|
193
197
|
- Up to 4 attempts with exponential backoff + jitter (capped at 8 s)
|
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
22
|
+
calleeText,
|
|
19
23
|
callText: callText.length > CALL_TEXT_MAX_LENGTH ? `${callText.slice(0, CALL_TEXT_MAX_LENGTH)}…` : callText,
|
|
20
24
|
resolvedValues,
|
|
21
25
|
});
|