@flisk/analyze-tracking 0.8.7 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,25 +95,25 @@ Use this to understand where your events live in the code and how they're being
95
95
  Your LLM of choice is used for generating descriptions of events, properties, and implementations.
96
96
 
97
97
  See [schema.json](schema.json) for a JSON Schema of the output.
98
-
98
+
99
99
 
100
100
  ## Supported tracking libraries & languages
101
101
 
102
- | Library | JavaScript/TypeScript | Python | Ruby | Go |
103
- |---------|:---------------------:|:------:|:----:|:--:|
104
- | Google Analytics | ✅ | ❌ | ❌ | ❌ |
105
- | Google Tag Manager | ✅ | ❌ | ❌ | ❌ |
106
- | Segment | ✅ | ✅ | ✅ | ✅ |
107
- | Mixpanel | ✅ | ✅ | ✅ | ✅ |
108
- | Amplitude | ✅ | ✅ | ❌ | ✅ |
109
- | Rudderstack | ✅ | ✅ | ✳️ | ✳️ |
110
- | mParticle | ✅ | ❌ | ❌ | ❌ |
111
- | PostHog | ✅ | ✅ | ✅ | ✅ |
112
- | Pendo | ✅ | ❌ | ❌ | ❌ |
113
- | Heap | ✅ | ❌ | ❌ | ❌ |
114
- | Snowplow | ✅ | ✅ | ✅ | ✅ |
115
- | Datadog RUM | ✅ | ❌ | ❌ | ❌ |
116
- | Custom Function | ✅ | ✅ | ✅ | ✅ |
102
+ | Library | JavaScript/TypeScript | Python | Ruby | Go | Swift |
103
+ |---------|:---------------------:|:------:|:----:|:--:|:--:|
104
+ | Google Analytics | ✅ | ❌ | ❌ | ❌ | ✅ |
105
+ | Google Tag Manager | ✅ | ❌ | ❌ | ❌ | ✅ |
106
+ | Segment | ✅ | ✅ | ✅ | ✅ | ✅ |
107
+ | Mixpanel | ✅ | ✅ | ✅ | ✅ | ✅ |
108
+ | Amplitude | ✅ | ✅ | ❌ | ✅ | ✅ |
109
+ | Rudderstack | ✅ | ✅ | ✳️ | ✳️ | ✅ |
110
+ | mParticle | ✅ | ❌ | ❌ | ❌ | ✅ |
111
+ | PostHog | ✅ | ✅ | ✅ | ✅ | ✅ |
112
+ | Pendo | ✅ | ❌ | ❌ | ❌ | ✅ |
113
+ | Heap | ✅ | ❌ | ❌ | ❌ | ✅ |
114
+ | Snowplow | ✅ | ✅ | ✅ | ✅ | ❌ |
115
+ | Datadog RUM | ✅ | ❌ | ❌ | ❌ | ❌ |
116
+ | Custom Function | ✅ | ✅ | ✅ | ✅ | ✅ |
117
117
 
118
118
  ✳️ Rudderstack's SDKs often use the same format as Segment, so Rudderstack events may be detected as Segment events.
119
119
 
@@ -129,6 +129,13 @@ See [schema.json](schema.json) for a JSON Schema of the output.
129
129
  '<property_name>': '<property_value>'
130
130
  });
131
131
  ```
132
+
133
+ **Swift**
134
+ ```swift
135
+ Analytics.logEvent("<event_name>", parameters: [
136
+ "<property_name>": "<property_value>"
137
+ ])
138
+ ```
132
139
  </details>
133
140
 
134
141
  <details>
@@ -147,6 +154,11 @@ See [schema.json](schema.json) for a JSON Schema of the output.
147
154
  '<property_name>': '<property_value>'
148
155
  });
149
156
  ```
157
+
158
+ **Swift**
159
+ ```swift
160
+ dataLayer.push(["event": "<event_name>", "<property_name>": "<property_value>"])
161
+ ```
150
162
  </details>
151
163
 
152
164
  <details>
@@ -185,6 +197,11 @@ See [schema.json](schema.json) for a JSON Schema of the output.
185
197
  Set("<property_name>", "<property_value>"),
186
198
  })
187
199
  ```
200
+
201
+ **Swift**
202
+ ```swift
203
+ analytics.track(name: "<event_name>", properties: TrackProperties("<property_name>": "<property_value>"))
204
+ ```
188
205
  </details>
189
206
 
190
207
  <details>
@@ -221,6 +238,13 @@ See [schema.json](schema.json) for a JSON Schema of the output.
221
238
  }),
222
239
  })
223
240
  ```
241
+
242
+ **Swift**
243
+ ```swift
244
+ Mixpanel.mainInstance().track(event: "<event_name>", properties: [
245
+ "<property_name>": "<property_value>"
246
+ ])
247
+ ```
224
248
  </details>
225
249
 
226
250
  <details>
@@ -256,6 +280,14 @@ See [schema.json](schema.json) for a JSON Schema of the output.
256
280
  },
257
281
  })
258
282
  ```
283
+
284
+ **Swift**
285
+ ```swift
286
+ amplitude.track(
287
+ eventType: "<event_name>",
288
+ eventProperties: ["<property_name>": "<property_value>"]
289
+ )
290
+ ```
259
291
  </details>
260
292
 
261
293
  <details>
@@ -295,6 +327,13 @@ See [schema.json](schema.json) for a JSON Schema of the output.
295
327
  Set("<property_name>", "<property_value>"),
296
328
  })
297
329
  ```
330
+
331
+ **Swift**
332
+ ```swift
333
+ RSClient.sharedInstance()?.track("<event_name>", properties: [
334
+ "<property_name>": "<property_value>"
335
+ ])
336
+ ```
298
337
  </details>
299
338
 
300
339
  <details>
@@ -306,6 +345,15 @@ See [schema.json](schema.json) for a JSON Schema of the output.
306
345
  '<property_name>': '<property_value>'
307
346
  });
308
347
  ```
348
+
349
+ **Swift**
350
+ ```swift
351
+ let event = MPEvent(name: "<event_name>", type: .other)
352
+ event.customAttributes = [
353
+ "<property_name>": "<property_value>"
354
+ ]
355
+ MParticle.sharedInstance().logEvent(event)
356
+ ```
309
357
  </details>
310
358
 
311
359
  <details>
@@ -353,6 +401,13 @@ See [schema.json](schema.json) for a JSON Schema of the output.
353
401
  Set("<property_name>", "<property_value>"),
354
402
  })
355
403
  ```
404
+
405
+ **Swift**
406
+ ```swift
407
+ PostHogSDK.shared.capture("<event_name>", properties: [
408
+ "<property_name>": "<property_value>"
409
+ ])
410
+ ```
356
411
  </details>
357
412
 
358
413
  <details>
@@ -372,7 +427,12 @@ See [schema.json](schema.json) for a JSON Schema of the output.
372
427
  })
373
428
  ```
374
429
 
375
-
430
+ **Swift**
431
+ ```swift
432
+ PendoManager.shared().track("<event_name>", properties: [
433
+ "<property_name>": "<property_value>"
434
+ ])
435
+ ```
376
436
  </details>
377
437
 
378
438
  <details>
@@ -392,7 +452,12 @@ See [schema.json](schema.json) for a JSON Schema of the output.
392
452
  })
393
453
  ```
394
454
 
395
-
455
+ **Swift**
456
+ ```swift
457
+ Heap.shared.track("<event_name>", properties: [
458
+ "<property_name>": "<property_value>"
459
+ ])
460
+ ```
396
461
  </details>
397
462
 
398
463
  <details>
@@ -403,7 +468,7 @@ See [schema.json](schema.json) for a JSON Schema of the output.
403
468
  datadogRum.addAction('<event_name>', {
404
469
  '<property_name>': '<property_value>'
405
470
  });
406
-
471
+
407
472
  // Or via window
408
473
  window.DD_RUM.addAction('<event_name>', {
409
474
  '<property_name>': '<property_value>'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flisk/analyze-tracking",
3
- "version": "0.8.7",
3
+ "version": "0.9.0",
4
4
  "description": "Analyzes tracking code in a project and generates data schemas",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -13,6 +13,7 @@
13
13
  "test:python": "node --experimental-vm-modules --test tests/analyzePython.test.js",
14
14
  "test:ruby": "node --experimental-vm-modules --test tests/analyzeRuby.test.js",
15
15
  "test:go": "node --test tests/analyzeGo.test.js",
16
+ "test:swift": "node --test tests/analyzeSwift.test.js",
16
17
  "test:cli": "node --test tests/cli.test.js",
17
18
  "test:schema": "node --test tests/schema.test.js",
18
19
  "test:generateDescriptions": "node --test tests/generateDescriptions.test.js",
@@ -34,6 +35,7 @@
34
35
  },
35
36
  "homepage": "https://github.com/fliskdata/analyze-tracking#readme",
36
37
  "dependencies": {
38
+ "@flisk/swift-ast": "^0.1.3",
37
39
  "@langchain/core": "^0.3.56",
38
40
  "@langchain/google-vertexai": "^0.2.9",
39
41
  "@langchain/openai": "^0.5.10",
@@ -52,9 +54,9 @@
52
54
  "zod": "^3.24.4"
53
55
  },
54
56
  "devDependencies": {
57
+ "@types/react": "^19.1.6",
55
58
  "ajv": "^8.17.1",
56
59
  "lodash": "^4.17.21",
57
- "react": "^19.1.0",
58
- "@types/react": "^19.1.6"
60
+ "react": "^19.1.0"
59
61
  }
60
62
  }
@@ -13,12 +13,13 @@ const { analyzeTsFiles } = require('./typescript');
13
13
  const { analyzePythonFile } = require('./python');
14
14
  const { analyzeRubyFile, prebuildConstantMaps } = require('./ruby');
15
15
  const { analyzeGoFile } = require('./go');
16
+ const { analyzeSwiftFile } = require('./swift');
16
17
 
17
18
  /**
18
19
  * Analyzes a single file for analytics tracking calls
19
- *
20
+ *
20
21
  * Note: typescript files are handled separately by analyzeTsFiles, which is a batch processor
21
- *
22
+ *
22
23
  * @param {string} file - Path to the file to analyze
23
24
  * @param {Array<string>} customFunctionSignatures - Custom function signatures to detect
24
25
  * @returns {Promise<Array<Object>>} Array of events found in the file
@@ -28,19 +29,20 @@ async function analyzeFile(file, customFunctionSignatures) {
28
29
  if (/\.py$/.test(file)) return analyzePythonFile(file, customFunctionSignatures)
29
30
  if (/\.rb$/.test(file)) return analyzeRubyFile(file, customFunctionSignatures)
30
31
  if (/\.go$/.test(file)) return analyzeGoFile(file, customFunctionSignatures)
32
+ if (/\.swift$/.test(file)) return analyzeSwiftFile(file, customFunctionSignatures)
31
33
  return []
32
34
  }
33
35
 
34
36
  /**
35
37
  * Adds an event to the events collection, merging properties if event already exists
36
- *
38
+ *
37
39
  * @param {Object} allEvents - Collection of all events
38
40
  * @param {Object} event - Event to add
39
41
  * @param {string} baseDir - Base directory for relative path calculation
40
42
  */
41
43
  function addEventToCollection(allEvents, event, baseDir) {
42
44
  const relativeFilePath = path.relative(baseDir, event.filePath);
43
-
45
+
44
46
  const implementation = {
45
47
  path: relativeFilePath,
46
48
  line: event.line,
@@ -64,12 +66,12 @@ function addEventToCollection(allEvents, event, baseDir) {
64
66
 
65
67
  /**
66
68
  * Processes all files that are not TypeScript files in parallel
67
- *
69
+ *
68
70
  * Checks the system's file descriptor limit and uses 80% of it to avoid running out of file descriptors
69
71
  * Creates a promise pool and launches one analysis for each file in parallel
70
72
  * When a slot frees up, the next file is launched
71
73
  * Waits for the remaining work to complete
72
- *
74
+ *
73
75
  * @param {Array<string>} files - Array of file paths
74
76
  * @param {Object} allEvents - Collection to add events to
75
77
  * @param {string} baseDir - Base directory for relative paths
@@ -121,10 +123,10 @@ async function processFiles(files, allEvents, baseDir, customFunctionSignatures)
121
123
 
122
124
  /**
123
125
  * Analyze a directory recursively for analytics tracking calls
124
- *
126
+ *
125
127
  * This function scans all supported files in a directory tree and identifies analytics tracking calls,
126
128
  * handling different file types appropriately.
127
- *
129
+ *
128
130
  * @param {string} dirPath - Path to the directory to analyze
129
131
  * @param {Array<string>} [customFunctions=null] - Array of custom tracking function signatures to detect
130
132
  * @returns {Promise<Object>} Object mapping event names to their tracking implementations
@@ -132,17 +134,22 @@ async function processFiles(files, allEvents, baseDir, customFunctionSignatures)
132
134
  async function analyzeDirectory(dirPath, customFunctions) {
133
135
  const allEvents = {};
134
136
 
135
- const customFunctionSignatures = (customFunctions?.length > 0)
136
- ? customFunctions.map(parseCustomFunctionSignature)
137
- : null;
137
+ let customFunctionSignatures = null;
138
+ if (Array.isArray(customFunctions) && customFunctions.length > 0) {
139
+ customFunctionSignatures = customFunctions.map(cf => {
140
+ if (cf && typeof cf === 'object' && cf.functionName) return cf;
141
+ if (typeof cf === 'string') return parseCustomFunctionSignature(cf);
142
+ return null;
143
+ }).filter(Boolean);
144
+ }
138
145
 
139
146
  const files = getAllFiles(dirPath);
140
-
147
+
141
148
  // Separate TypeScript files from others for optimized processing
142
149
  const tsFiles = [];
143
150
  const nonTsFiles = [];
144
151
  const rubyFiles = [];
145
-
152
+
146
153
  for (const file of files) {
147
154
  const isTsFile = /\.(tsx?)$/.test(file);
148
155
  if (isTsFile) {
@@ -45,20 +45,23 @@ function detectAnalyticsSource(node, customFunction) {
45
45
  function isCustomFunction(node, customFunction) {
46
46
  if (!customFunction) return false;
47
47
 
48
- // Support dot-separated names like "CustomModule.track"
49
- const parts = customFunction.split('.');
48
+ // Support dot-separated names like "CustomModule.track" and chained calls like "getTrackingService().track"
49
+ // Normalize each segment by stripping trailing parentheses
50
+ const parts = customFunction.split('.').map(p => p.replace(/\(\s*\)$/, ''));
50
51
 
51
52
  // Simple identifier (no dot)
52
53
  if (parts.length === 1) {
53
- return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === customFunction;
54
+ return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === parts[0];
54
55
  }
55
56
 
56
- // For dot-separated names, the callee should be a MemberExpression chain.
57
- if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
57
+ // For dot-separated names, the callee should be a MemberExpression chain,
58
+ // but we also allow CallExpression in the chain (e.g., getService().track)
59
+ const callee = node.callee;
60
+ if (callee.type !== NODE_TYPES.MEMBER_EXPRESSION && callee.type !== NODE_TYPES.CALL_EXPRESSION) {
58
61
  return false;
59
62
  }
60
63
 
61
- return matchesMemberChain(node.callee, parts);
64
+ return matchesMemberChain(callee, parts);
62
65
  }
63
66
 
64
67
  /**
@@ -75,9 +78,8 @@ function matchesMemberChain(memberExpr, parts) {
75
78
  while (currentNode && idx >= 0) {
76
79
  const expectedPart = parts[idx];
77
80
 
78
- // property should match current expectedPart
79
81
  if (currentNode.type === NODE_TYPES.MEMBER_EXPRESSION) {
80
- // Ensure property is Identifier and matches
82
+ // Ensure property is Identifier and matches the expected part
81
83
  if (
82
84
  currentNode.property.type !== NODE_TYPES.IDENTIFIER ||
83
85
  currentNode.property.name !== expectedPart
@@ -85,16 +87,25 @@ function matchesMemberChain(memberExpr, parts) {
85
87
  return false;
86
88
  }
87
89
 
88
- // Move to the object of the MemberExpression
90
+ // Move to the object (which could itself be a MemberExpression, Identifier, or CallExpression)
89
91
  currentNode = currentNode.object;
90
92
  idx -= 1;
91
- } else if (currentNode.type === NODE_TYPES.IDENTIFIER) {
92
- // We reached the leftmost Identifier; it should match the first part
93
+ continue;
94
+ }
95
+
96
+ // If we encounter a CallExpression in the chain (e.g., getService().track),
97
+ // step into its callee without consuming an expected part.
98
+ if (currentNode.type === NODE_TYPES.CALL_EXPRESSION) {
99
+ currentNode = currentNode.callee;
100
+ continue;
101
+ }
102
+
103
+ if (currentNode.type === NODE_TYPES.IDENTIFIER) {
93
104
  return idx === 0 && currentNode.name === expectedPart;
94
- } else {
95
- // Unexpected node type (e.g., ThisExpression, CallExpression, etc.)
96
- return false;
97
105
  }
106
+
107
+ // Unexpected node type (e.g., ThisExpression, Literal, etc.)
108
+ return false;
98
109
  }
99
110
 
100
111
  return false;
@@ -257,7 +257,11 @@ function getStringValue(node, constantMap = {}) {
257
257
  return node.value;
258
258
  }
259
259
  if (node.type === NODE_TYPES.MEMBER_EXPRESSION) {
260
- return resolveMemberExpressionToString(node, constantMap);
260
+ const resolved = resolveMemberExpressionToString(node, constantMap);
261
+ if (resolved) return resolved;
262
+ // Fallback: return a dotted path for member expressions when we cannot
263
+ // resolve to a literal (e.g., imported constants like TELEMETRY_EVENTS.X)
264
+ return memberExpressionToPath(node);
261
265
  }
262
266
  return null;
263
267
  }
@@ -315,6 +319,25 @@ function resolveMemberExpressionToString(node, constantMap) {
315
319
  return null;
316
320
  }
317
321
 
322
+ // Build a dotted path string for a MemberExpression (e.g., OBJ.KEY.SUBKEY)
323
+ function memberExpressionToPath(node) {
324
+ if (!node || node.type !== NODE_TYPES.MEMBER_EXPRESSION) return null;
325
+ const parts = [];
326
+ let current = node;
327
+ while (current && current.type === NODE_TYPES.MEMBER_EXPRESSION && !current.computed) {
328
+ if (current.property && current.property.type === NODE_TYPES.IDENTIFIER) {
329
+ parts.unshift(current.property.name);
330
+ } else if (current.property && current.property.type === NODE_TYPES.LITERAL) {
331
+ parts.unshift(String(current.property.value));
332
+ }
333
+ current = current.object;
334
+ }
335
+ if (current && current.type === NODE_TYPES.IDENTIFIER) {
336
+ parts.unshift(current.name);
337
+ }
338
+ return parts.length ? parts.join('.') : null;
339
+ }
340
+
318
341
  module.exports = {
319
342
  extractEventData,
320
343
  processEventData
@@ -12,6 +12,7 @@ const { PARSER_OPTIONS, NODE_TYPES } = require('./constants');
12
12
  const { detectAnalyticsSource } = require('./detectors');
13
13
  const { extractEventData, processEventData } = require('./extractors');
14
14
  const { findWrappingFunction } = require('./utils/function-finder');
15
+ const { collectImportedConstantStringMap } = require('./utils/import-resolver');
15
16
 
16
17
  // Extend walker to support JSX
17
18
  extend(walk.base);
@@ -82,19 +83,15 @@ function parseFile(filePath) {
82
83
  function nodeMatchesCustomFunction(node, fnName) {
83
84
  if (!fnName || !node.callee) return false;
84
85
 
85
- const parts = fnName.split('.');
86
+ // Support chained calls in function name by stripping trailing parens from each segment
87
+ const parts = fnName.split('.').map(p => p.replace(/\(\s*\)$/, ''));
86
88
 
87
89
  // Simple identifier case
88
90
  if (parts.length === 1) {
89
- return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === fnName;
91
+ return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === parts[0];
90
92
  }
91
93
 
92
- // Member expression chain case
93
- if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
94
- return false;
95
- }
96
-
97
- // Walk the chain from the right-most property to the leftmost object
94
+ // Allow MemberExpression and CallExpression within the chain (e.g., getService().track)
98
95
  let currentNode = node.callee;
99
96
  let idx = parts.length - 1;
100
97
 
@@ -108,13 +105,23 @@ function nodeMatchesCustomFunction(node, fnName) {
108
105
  ) {
109
106
  return false;
110
107
  }
108
+ // step to the object; do not decrement idx for call expressions yet
111
109
  currentNode = currentNode.object;
112
110
  idx -= 1;
113
- } else if (currentNode.type === NODE_TYPES.IDENTIFIER) {
111
+ continue;
112
+ }
113
+
114
+ if (currentNode.type === NODE_TYPES.CALL_EXPRESSION) {
115
+ // descend into the callee of the call without consuming a part
116
+ currentNode = currentNode.callee;
117
+ continue;
118
+ }
119
+
120
+ if (currentNode.type === NODE_TYPES.IDENTIFIER) {
114
121
  return idx === 0 && currentNode.name === expected;
115
- } else {
116
- return false;
117
122
  }
123
+
124
+ return false;
118
125
  }
119
126
 
120
127
  return false;
@@ -183,8 +190,11 @@ function collectConstantStringMap(ast) {
183
190
  function findTrackingEvents(ast, filePath, customConfigs = []) {
184
191
  const events = [];
185
192
 
186
- // Collect constant mappings once per file
187
- const constantMap = collectConstantStringMap(ast);
193
+ // Collect constant mappings once per file (locals + imported)
194
+ const constantMap = {
195
+ ...collectConstantStringMap(ast),
196
+ ...collectImportedConstantStringMap(filePath, ast)
197
+ };
188
198
 
189
199
  walk.ancestor(ast, {
190
200
  [NODE_TYPES.CALL_EXPRESSION]: (node, ancestors) => {