@flisk/analyze-tracking 0.7.4 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flisk/analyze-tracking",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "Analyzes tracking code in a project and generates data schemas",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -36,7 +36,18 @@ function detectSource(node, customFunction = null) {
36
36
  if (node.name === 'track_struct_event') return 'snowplow';
37
37
 
38
38
  // Custom tracking function
39
- if (customFunction && node.name === customFunction) return 'custom';
39
+ if (customFunction) {
40
+ // Handle simple function names (e.g., 'customTrackFunction')
41
+ if (node.name === customFunction) return 'custom';
42
+
43
+ // Handle module-scoped function names (e.g., 'CustomModule.track')
44
+ if (customFunction.includes('.')) {
45
+ const [moduleName, methodName] = customFunction.split('.');
46
+ if (node.receiver && node.receiver.name === moduleName && node.name === methodName) {
47
+ return 'custom';
48
+ }
49
+ }
50
+ }
40
51
 
41
52
  return null;
42
53
  }
@@ -14,14 +14,17 @@ const { getValueType } = require('./types');
14
14
  function extractEventName(node, source) {
15
15
  if (source === 'segment' || source === 'rudderstack') {
16
16
  // Both Segment and Rudderstack use the same format
17
- const params = node.arguments_.arguments_[0].elements;
17
+ const params = node.arguments_?.arguments_?.[0]?.elements;
18
+ if (!params || !Array.isArray(params)) {
19
+ return null;
20
+ }
18
21
  const eventProperty = params.find(param => param?.key?.unescaped?.value === 'event');
19
22
  return eventProperty?.value?.unescaped?.value || null;
20
23
  }
21
24
 
22
25
  if (source === 'mixpanel') {
23
26
  // Mixpanel Ruby SDK format: tracker.track('distinct_id', 'event_name', {...})
24
- const args = node.arguments_.arguments_;
27
+ const args = node.arguments_?.arguments_;
25
28
  if (args && args.length > 1 && args[1]?.unescaped?.value) {
26
29
  return args[1].unescaped.value;
27
30
  }
@@ -29,8 +32,8 @@ function extractEventName(node, source) {
29
32
 
30
33
  if (source === 'posthog') {
31
34
  // PostHog Ruby SDK format: posthog.capture({distinct_id: '...', event: '...', properties: {...}})
32
- const hashArg = node.arguments_.arguments_[0];
33
- if (hashArg && hashArg.elements) {
35
+ const hashArg = node.arguments_?.arguments_?.[0];
36
+ if (hashArg && hashArg.elements && Array.isArray(hashArg.elements)) {
34
37
  const eventProperty = hashArg.elements.find(elem => elem?.key?.unescaped?.value === 'event');
35
38
  return eventProperty?.value?.unescaped?.value || null;
36
39
  }
@@ -38,14 +41,17 @@ function extractEventName(node, source) {
38
41
 
39
42
  if (source === 'snowplow') {
40
43
  // Snowplow Ruby SDK: tracker.track_struct_event(category: '...', action: '...', ...)
41
- const params = node.arguments_.arguments_[0].elements;
44
+ const params = node.arguments_?.arguments_?.[0]?.elements;
45
+ if (!params || !Array.isArray(params)) {
46
+ return null;
47
+ }
42
48
  const actionProperty = params.find(param => param?.key?.unescaped?.value === 'action');
43
49
  return actionProperty?.value?.unescaped?.value || null;
44
50
  }
45
51
 
46
52
  if (source === 'custom') {
47
53
  // Custom function format: customFunction('event_name', {...})
48
- const args = node.arguments_.arguments_;
54
+ const args = node.arguments_?.arguments_;
49
55
  if (args && args.length > 0 && args[0]?.unescaped?.value) {
50
56
  return args[0].unescaped.value;
51
57
  }
@@ -65,7 +71,10 @@ async function extractProperties(node, source) {
65
71
 
66
72
  if (source === 'segment' || source === 'rudderstack') {
67
73
  // Both Segment and Rudderstack use the same format
68
- const params = node.arguments_.arguments_[0].elements;
74
+ const params = node.arguments_?.arguments_?.[0]?.elements;
75
+ if (!params || !Array.isArray(params)) {
76
+ return null;
77
+ }
69
78
  const properties = {};
70
79
 
71
80
  // Process all top-level fields except 'event'
@@ -108,7 +117,7 @@ async function extractProperties(node, source) {
108
117
 
109
118
  if (source === 'mixpanel') {
110
119
  // Mixpanel Ruby SDK: tracker.track('distinct_id', 'event_name', {properties})
111
- const args = node.arguments_.arguments_;
120
+ const args = node.arguments_?.arguments_;
112
121
  const properties = {};
113
122
 
114
123
  // Add distinct_id as property (even if it's a variable)
@@ -129,10 +138,10 @@ async function extractProperties(node, source) {
129
138
 
130
139
  if (source === 'posthog') {
131
140
  // PostHog Ruby SDK: posthog.capture({distinct_id: '...', event: '...', properties: {...}})
132
- const hashArg = node.arguments_.arguments_[0];
141
+ const hashArg = node.arguments_?.arguments_?.[0];
133
142
  const properties = {};
134
143
 
135
- if (hashArg && hashArg.elements) {
144
+ if (hashArg && hashArg.elements && Array.isArray(hashArg.elements)) {
136
145
  // Extract distinct_id if present
137
146
  const distinctIdProperty = hashArg.elements.find(elem => elem?.key?.unescaped?.value === 'distinct_id');
138
147
  if (distinctIdProperty?.value) {
@@ -154,7 +163,10 @@ async function extractProperties(node, source) {
154
163
 
155
164
  if (source === 'snowplow') {
156
165
  // Snowplow Ruby SDK: tracker.track_struct_event(category: '...', action: '...', ...)
157
- const params = node.arguments_.arguments_[0].elements;
166
+ const params = node.arguments_?.arguments_?.[0]?.elements;
167
+ if (!params || !Array.isArray(params)) {
168
+ return null;
169
+ }
158
170
  const properties = {};
159
171
 
160
172
  // Extract all struct event parameters except 'action' (which is used as the event name)
@@ -172,7 +184,7 @@ async function extractProperties(node, source) {
172
184
 
173
185
  if (source === 'custom') {
174
186
  // Custom function format: customFunction('event_name', {properties})
175
- const args = node.arguments_.arguments_;
187
+ const args = node.arguments_?.arguments_;
176
188
  if (args && args.length > 1 && args[1] instanceof HashNode) {
177
189
  return await extractHashProperties(args[1]);
178
190
  }
@@ -29,7 +29,16 @@ class TrackingVisitor {
29
29
  if (!eventName) return;
30
30
 
31
31
  const line = getLineNumber(this.code, node.location);
32
- const functionName = await findWrappingFunction(node, ancestors);
32
+
33
+ // For module-scoped custom functions, use the custom function name as the functionName
34
+ // For simple custom functions, use the wrapping function name
35
+ let functionName;
36
+ if (source === 'custom' && this.customFunction && this.customFunction.includes('.')) {
37
+ functionName = this.customFunction;
38
+ } else {
39
+ functionName = await findWrappingFunction(node, ancestors);
40
+ }
41
+
33
42
  const properties = await extractProperties(node, source);
34
43
 
35
44
  this.events.push({
@@ -48,7 +48,10 @@ function isCustomFunction(node, customFunction) {
48
48
  ts.isPropertyAccessExpression(node.expression) ||
49
49
  ts.isCallExpression(node.expression) || // For chained calls like getTracker().track()
50
50
  ts.isElementAccessExpression(node.expression) || // For array/object access like trackers['analytics'].track()
51
- (ts.isPropertyAccessExpression(node.expression?.expression) && ts.isThisExpression(node.expression.expression.expression)); // For class methods like this.analytics.track()
51
+ (node.expression?.expression &&
52
+ ts.isPropertyAccessExpression(node.expression.expression) &&
53
+ node.expression.expression.expression &&
54
+ ts.isThisExpression(node.expression.expression.expression)); // For class methods like this.analytics.track()
52
55
 
53
56
  return canBeCustomFunction && node.expression.getText() === customFunction;
54
57
  }
@@ -84,8 +87,8 @@ function detectMemberBasedProvider(node) {
84
87
  return 'unknown';
85
88
  }
86
89
 
87
- const objectName = node.expression.expression.escapedText;
88
- const methodName = node.expression.name.escapedText;
90
+ const objectName = node.expression.expression?.escapedText;
91
+ const methodName = node.expression.name?.escapedText;
89
92
 
90
93
  if (!objectName || !methodName) {
91
94
  return 'unknown';
@@ -50,7 +50,7 @@ function extractGoogleAnalyticsEvent(node, checker, sourceFile) {
50
50
  }
51
51
 
52
52
  // gtag('event', 'event_name', { properties })
53
- const eventName = getStringValue(node.arguments[1]);
53
+ const eventName = getStringValue(node.arguments[1], checker, sourceFile);
54
54
  const propertiesNode = node.arguments[2];
55
55
 
56
56
  return { eventName, propertiesNode };
@@ -79,7 +79,7 @@ function extractSnowplowEvent(node, checker, sourceFile) {
79
79
  const structEventArg = firstArg.arguments[0];
80
80
  if (ts.isObjectLiteralExpression(structEventArg)) {
81
81
  const actionProperty = findPropertyByKey(structEventArg, 'action');
82
- const eventName = actionProperty ? getStringValue(actionProperty.initializer) : null;
82
+ const eventName = actionProperty ? getStringValue(actionProperty.initializer, checker, sourceFile) : null;
83
83
  return { eventName, propertiesNode: structEventArg };
84
84
  }
85
85
  }
@@ -93,7 +93,7 @@ function extractSnowplowEvent(node, checker, sourceFile) {
93
93
  const structEventArg = resolvedNode.arguments[0];
94
94
  if (ts.isObjectLiteralExpression(structEventArg)) {
95
95
  const actionProperty = findPropertyByKey(structEventArg, 'action');
96
- const eventName = actionProperty ? getStringValue(actionProperty.initializer) : null;
96
+ const eventName = actionProperty ? getStringValue(actionProperty.initializer, checker, sourceFile) : null;
97
97
  return { eventName, propertiesNode: structEventArg };
98
98
  }
99
99
  }
@@ -115,7 +115,7 @@ function extractMparticleEvent(node, checker, sourceFile) {
115
115
  }
116
116
 
117
117
  // mParticle.logEvent('event_name', mParticle.EventType.Navigation, { properties })
118
- const eventName = getStringValue(node.arguments[0]);
118
+ const eventName = getStringValue(node.arguments[0], checker, sourceFile);
119
119
  const propertiesNode = node.arguments[2];
120
120
 
121
121
  return { eventName, propertiesNode };
@@ -134,7 +134,7 @@ function extractDefaultEvent(node, checker, sourceFile) {
134
134
  }
135
135
 
136
136
  // provider.track('event_name', { properties })
137
- const eventName = getStringValue(node.arguments[0]);
137
+ const eventName = getStringValue(node.arguments[0], checker, sourceFile);
138
138
  const propertiesNode = node.arguments[1];
139
139
 
140
140
  return { eventName, propertiesNode };
@@ -197,16 +197,121 @@ function processEventData(eventData, source, filePath, line, functionName, check
197
197
  /**
198
198
  * Gets string value from a TypeScript AST node
199
199
  * @param {Object} node - TypeScript AST node
200
+ * @param {Object} checker - TypeScript type checker
201
+ * @param {Object} sourceFile - TypeScript source file
200
202
  * @returns {string|null} String value or null
201
203
  */
202
- function getStringValue(node) {
204
+ function getStringValue(node, checker, sourceFile) {
203
205
  if (!node) return null;
206
+
207
+ // Handle string literals (existing behavior)
204
208
  if (ts.isStringLiteral(node)) {
205
209
  return node.text;
206
210
  }
211
+
212
+ // Handle property access expressions like TRACKING_EVENTS.ECOMMERCE_PURCHASE
213
+ if (ts.isPropertyAccessExpression(node)) {
214
+ return resolvePropertyAccessToString(node, checker, sourceFile);
215
+ }
216
+
217
+ // Handle identifiers that might reference constants
218
+ if (ts.isIdentifier(node)) {
219
+ return resolveIdentifierToString(node, checker, sourceFile);
220
+ }
221
+
207
222
  return null;
208
223
  }
209
224
 
225
+ /**
226
+ * Resolves a property access expression to its string value
227
+ * @param {Object} node - PropertyAccessExpression node
228
+ * @param {Object} checker - TypeScript type checker
229
+ * @param {Object} sourceFile - TypeScript source file
230
+ * @returns {string|null} String value or null
231
+ */
232
+ function resolvePropertyAccessToString(node, checker, sourceFile) {
233
+ try {
234
+ // Get the symbol for the property access
235
+ const symbol = checker.getSymbolAtLocation(node);
236
+ if (!symbol || !symbol.valueDeclaration) {
237
+ return null;
238
+ }
239
+
240
+ // Check if it's a property assignment with a string initializer
241
+ if (ts.isPropertyAssignment(symbol.valueDeclaration) &&
242
+ symbol.valueDeclaration.initializer &&
243
+ ts.isStringLiteral(symbol.valueDeclaration.initializer)) {
244
+ return symbol.valueDeclaration.initializer.text;
245
+ }
246
+
247
+ // Check if it's a variable declaration property
248
+ if (ts.isPropertySignature(symbol.valueDeclaration) ||
249
+ ts.isMethodSignature(symbol.valueDeclaration)) {
250
+ // Try to get the type and see if it's a string literal type
251
+ const type = checker.getTypeAtLocation(node);
252
+ if (type.isStringLiteral && type.isStringLiteral()) {
253
+ return type.value;
254
+ }
255
+ }
256
+
257
+ return null;
258
+ } catch (error) {
259
+ return null;
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Resolves an identifier to its string value
265
+ * @param {Object} node - Identifier node
266
+ * @param {Object} checker - TypeScript type checker
267
+ * @param {Object} sourceFile - TypeScript source file
268
+ * @returns {string|null} String value or null
269
+ */
270
+ function resolveIdentifierToString(node, checker, sourceFile) {
271
+ try {
272
+ const symbol = checker.getSymbolAtLocation(node);
273
+ if (!symbol) {
274
+ return null;
275
+ }
276
+
277
+ // First try to resolve through value declaration
278
+ if (symbol.valueDeclaration) {
279
+ const declaration = symbol.valueDeclaration;
280
+
281
+ // Handle variable declarations with string literal initializers
282
+ if (ts.isVariableDeclaration(declaration) &&
283
+ declaration.initializer &&
284
+ ts.isStringLiteral(declaration.initializer)) {
285
+ return declaration.initializer.text;
286
+ }
287
+
288
+ // Handle const declarations with object literals containing string properties
289
+ if (ts.isVariableDeclaration(declaration) &&
290
+ declaration.initializer &&
291
+ ts.isObjectLiteralExpression(declaration.initializer)) {
292
+ // This case is handled by property access resolution
293
+ return null;
294
+ }
295
+ }
296
+
297
+ // If value declaration doesn't exist or doesn't help, try type resolution
298
+ // This handles imported constants that are resolved through TypeScript's type system
299
+ const type = checker.getTypeOfSymbolAtLocation(symbol, node);
300
+ if (type && type.isStringLiteral && typeof type.isStringLiteral === 'function' && type.isStringLiteral()) {
301
+ return type.value;
302
+ }
303
+
304
+ // Alternative approach for string literal types (different TypeScript versions)
305
+ if (type && type.flags && (type.flags & ts.TypeFlags.StringLiteral)) {
306
+ return type.value;
307
+ }
308
+
309
+ return null;
310
+ } catch (error) {
311
+ return null;
312
+ }
313
+ }
314
+
210
315
  /**
211
316
  * Finds a property by key in an ObjectLiteralExpression
212
317
  * @param {Object} objectNode - ObjectLiteralExpression node
@@ -34,6 +34,13 @@ function extractProperties(checker, node) {
34
34
  const properties = {};
35
35
 
36
36
  for (const prop of node.properties) {
37
+ // Handle spread assignments like {...object}
38
+ if (ts.isSpreadAssignment(prop)) {
39
+ const spreadProperties = extractSpreadProperties(checker, prop);
40
+ Object.assign(properties, spreadProperties);
41
+ continue;
42
+ }
43
+
37
44
  const key = getPropertyKey(prop);
38
45
  if (!key) continue;
39
46
 
@@ -350,6 +357,7 @@ function resolveTypeSchema(checker, typeString) {
350
357
  * @returns {string|null} Literal type or null
351
358
  */
352
359
  function getLiteralType(node) {
360
+ if (!node || typeof node.kind === 'undefined') return null;
353
361
  if (ts.isStringLiteral(node)) return 'string';
354
362
  if (ts.isNumericLiteral(node)) return 'number';
355
363
  if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) return 'boolean';
@@ -370,6 +378,51 @@ function isArrayType(typeString) {
370
378
  typeString.startsWith('readonly ');
371
379
  }
372
380
 
381
+ /**
382
+ * Extracts properties from a spread assignment
383
+ * @param {Object} checker - TypeScript type checker
384
+ * @param {Object} spreadNode - SpreadAssignment node
385
+ * @returns {Object.<string, PropertySchema>}
386
+ */
387
+ function extractSpreadProperties(checker, spreadNode) {
388
+ if (!spreadNode.expression) {
389
+ return {};
390
+ }
391
+
392
+ // If the spread is an identifier, resolve it to its declaration
393
+ if (ts.isIdentifier(spreadNode.expression)) {
394
+ const symbol = checker.getSymbolAtLocation(spreadNode.expression);
395
+ if (symbol && symbol.declarations && symbol.declarations.length > 0) {
396
+ const declaration = symbol.declarations[0];
397
+
398
+ // If it's a variable declaration with an object literal initializer
399
+ if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
400
+ if (ts.isObjectLiteralExpression(declaration.initializer)) {
401
+ // Extract properties directly from the object literal
402
+ return extractProperties(checker, declaration.initializer);
403
+ }
404
+ }
405
+ }
406
+
407
+ // Fallback to the original identifier schema extraction
408
+ const identifierSchema = extractIdentifierSchema(checker, spreadNode.expression);
409
+ return identifierSchema.properties || {};
410
+ }
411
+
412
+ // If the spread is an object literal, extract its properties
413
+ if (ts.isObjectLiteralExpression(spreadNode.expression)) {
414
+ return extractProperties(checker, spreadNode.expression);
415
+ }
416
+
417
+ // For other expressions, try to get the type and extract properties from it
418
+ try {
419
+ const spreadType = checker.getTypeAtLocation(spreadNode.expression);
420
+ return extractInterfaceProperties(checker, spreadType);
421
+ } catch (error) {
422
+ return {};
423
+ }
424
+ }
425
+
373
426
  /**
374
427
  * Extracts properties from a TypeScript interface or type
375
428
  * @param {Object} checker - TypeScript type checker
@@ -93,7 +93,7 @@ function findParentFunctionName(node) {
93
93
  parent.initializer &&
94
94
  ts.isCallExpression(parent.initializer) &&
95
95
  ts.isIdentifier(parent.initializer.expression) &&
96
- REACT_HOOKS.has(parent.initializer.expression.escapedText)
96
+ isReactHookCall(parent.initializer)
97
97
  ) {
98
98
  return `${parent.initializer.expression.escapedText}(${parent.name.escapedText})`;
99
99
  }
@@ -110,6 +110,16 @@ function findParentFunctionName(node) {
110
110
  }
111
111
  }
112
112
 
113
+ // Property declaration in class: myFunc = () => {}
114
+ if (ts.isPropertyDeclaration(parent) && parent.name) {
115
+ if (ts.isIdentifier(parent.name)) {
116
+ return parent.name.escapedText;
117
+ }
118
+ if (ts.isStringLiteral(parent.name)) {
119
+ return parent.name.text;
120
+ }
121
+ }
122
+
113
123
  // Method property in object literal: { myFunc() {} }
114
124
  if (ts.isMethodDeclaration(parent) && parent.name) {
115
125
  return parent.name.escapedText;
@@ -117,6 +127,7 @@ function findParentFunctionName(node) {
117
127
 
118
128
  // Binary expression assignment: obj.myFunc = () => {}
119
129
  if (ts.isBinaryExpression(parent) &&
130
+ parent.operatorToken &&
120
131
  parent.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
121
132
  if (ts.isPropertyAccessExpression(parent.left)) {
122
133
  return parent.left.name.escapedText;
@@ -144,7 +144,7 @@ function isCustomType(typeString) {
144
144
  * @returns {string} Basic type string
145
145
  */
146
146
  function getBasicTypeOfArrayElement(checker, element) {
147
- if (!element) return 'any';
147
+ if (!element || typeof element.kind === 'undefined') return 'any';
148
148
 
149
149
  // Check for literal values first
150
150
  if (ts.isStringLiteral(element)) {