@flisk/analyze-tracking 0.7.6 → 0.8.1

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.
@@ -20,6 +20,7 @@ const EXTRACTION_STRATEGIES = {
20
20
  googleanalytics: extractGoogleAnalyticsEvent,
21
21
  snowplow: extractSnowplowEvent,
22
22
  mparticle: extractMparticleEvent,
23
+ custom: extractCustomEvent,
23
24
  default: extractDefaultEvent
24
25
  };
25
26
 
@@ -27,25 +28,31 @@ const EXTRACTION_STRATEGIES = {
27
28
  * Extracts event information from a CallExpression node
28
29
  * @param {Object} node - AST CallExpression node
29
30
  * @param {string} source - Analytics provider source
31
+ * @param {Object} constantMap - Collected constant map
32
+ * @param {Object} customConfig - Parsed custom function configuration
30
33
  * @returns {EventData} Extracted event data
31
34
  */
32
- function extractEventData(node, source) {
35
+ function extractEventData(node, source, constantMap = {}, customConfig) {
33
36
  const strategy = EXTRACTION_STRATEGIES[source] || EXTRACTION_STRATEGIES.default;
34
- return strategy(node);
37
+ if (source === 'custom') {
38
+ return strategy(node, constantMap, customConfig);
39
+ }
40
+ return strategy(node, constantMap);
35
41
  }
36
42
 
37
43
  /**
38
44
  * Extracts Google Analytics event data
39
45
  * @param {Object} node - CallExpression node
46
+ * @param {Object} constantMap - Collected constant map
40
47
  * @returns {EventData}
41
48
  */
42
- function extractGoogleAnalyticsEvent(node) {
49
+ function extractGoogleAnalyticsEvent(node, constantMap) {
43
50
  if (!node.arguments || node.arguments.length < 3) {
44
51
  return { eventName: null, propertiesNode: null };
45
52
  }
46
53
 
47
54
  // gtag('event', 'event_name', { properties })
48
- const eventName = getStringValue(node.arguments[1]);
55
+ const eventName = getStringValue(node.arguments[1], constantMap);
49
56
  const propertiesNode = node.arguments[2];
50
57
 
51
58
  return { eventName, propertiesNode };
@@ -54,9 +61,10 @@ function extractGoogleAnalyticsEvent(node) {
54
61
  /**
55
62
  * Extracts Snowplow event data
56
63
  * @param {Object} node - CallExpression node
64
+ * @param {Object} constantMap - Collected constant map
57
65
  * @returns {EventData}
58
66
  */
59
- function extractSnowplowEvent(node) {
67
+ function extractSnowplowEvent(node, constantMap) {
60
68
  if (!node.arguments || node.arguments.length === 0) {
61
69
  return { eventName: null, propertiesNode: null };
62
70
  }
@@ -70,7 +78,7 @@ function extractSnowplowEvent(node) {
70
78
 
71
79
  if (structEventArg.type === NODE_TYPES.OBJECT_EXPRESSION) {
72
80
  const actionProperty = findPropertyByKey(structEventArg, 'action');
73
- const eventName = actionProperty ? getStringValue(actionProperty.value) : null;
81
+ const eventName = actionProperty ? getStringValue(actionProperty.value, constantMap) : null;
74
82
 
75
83
  return { eventName, propertiesNode: structEventArg };
76
84
  }
@@ -82,15 +90,16 @@ function extractSnowplowEvent(node) {
82
90
  /**
83
91
  * Extracts mParticle event data
84
92
  * @param {Object} node - CallExpression node
93
+ * @param {Object} constantMap - Collected constant map
85
94
  * @returns {EventData}
86
95
  */
87
- function extractMparticleEvent(node) {
96
+ function extractMparticleEvent(node, constantMap) {
88
97
  if (!node.arguments || node.arguments.length < 3) {
89
98
  return { eventName: null, propertiesNode: null };
90
99
  }
91
100
 
92
101
  // mParticle.logEvent('event_name', mParticle.EventType.Navigation, { properties })
93
- const eventName = getStringValue(node.arguments[0]);
102
+ const eventName = getStringValue(node.arguments[0], constantMap);
94
103
  const propertiesNode = node.arguments[2];
95
104
 
96
105
  return { eventName, propertiesNode };
@@ -99,20 +108,46 @@ function extractMparticleEvent(node) {
99
108
  /**
100
109
  * Default event extraction for standard providers
101
110
  * @param {Object} node - CallExpression node
111
+ * @param {Object} constantMap - Collected constant map
102
112
  * @returns {EventData}
103
113
  */
104
- function extractDefaultEvent(node) {
114
+ function extractDefaultEvent(node, constantMap) {
105
115
  if (!node.arguments || node.arguments.length < 2) {
106
116
  return { eventName: null, propertiesNode: null };
107
117
  }
108
118
 
109
119
  // provider.track('event_name', { properties })
110
- const eventName = getStringValue(node.arguments[0]);
120
+ const eventName = getStringValue(node.arguments[0], constantMap);
111
121
  const propertiesNode = node.arguments[1];
112
122
 
113
123
  return { eventName, propertiesNode };
114
124
  }
115
125
 
126
+ /**
127
+ * Extracts Custom function event data according to signature
128
+ * @param {Object} node - CallExpression node
129
+ * @param {Object} constantMap - Collected constant map
130
+ * @param {Object} customConfig - Parsed custom function configuration
131
+ * @returns {EventData & {extraArgs:Object}} event data plus extra args map
132
+ */
133
+ function extractCustomEvent(node, constantMap, customConfig) {
134
+ const args = node.arguments || [];
135
+
136
+ const eventArg = args[customConfig?.eventIndex ?? 0];
137
+ const propertiesArg = args[customConfig?.propertiesIndex ?? 1];
138
+
139
+ const eventName = getStringValue(eventArg, constantMap);
140
+
141
+ const extraArgs = {};
142
+ if (customConfig && customConfig.extraParams) {
143
+ customConfig.extraParams.forEach(extra => {
144
+ extraArgs[extra.name] = args[extra.idx];
145
+ });
146
+ }
147
+
148
+ return { eventName, propertiesNode: propertiesArg, extraArgs };
149
+ }
150
+
116
151
  /**
117
152
  * Processes extracted event data into final event object
118
153
  * @param {EventData} eventData - Raw event data
@@ -120,9 +155,10 @@ function extractDefaultEvent(node) {
120
155
  * @param {string} filePath - File path
121
156
  * @param {number} line - Line number
122
157
  * @param {string} functionName - Containing function name
158
+ * @param {Object} customConfig - Parsed custom function configuration
123
159
  * @returns {Object|null} Processed event object or null
124
160
  */
125
- function processEventData(eventData, source, filePath, line, functionName) {
161
+ function processEventData(eventData, source, filePath, line, functionName, customConfig) {
126
162
  const { eventName, propertiesNode } = eventData;
127
163
 
128
164
  if (!eventName || !propertiesNode || propertiesNode.type !== NODE_TYPES.OBJECT_EXPRESSION) {
@@ -131,6 +167,24 @@ function processEventData(eventData, source, filePath, line, functionName) {
131
167
 
132
168
  let properties = extractProperties(propertiesNode);
133
169
 
170
+ // Handle custom extra params
171
+ if (source === 'custom' && customConfig && eventData.extraArgs) {
172
+ for (const [paramName, argNode] of Object.entries(eventData.extraArgs)) {
173
+ if (argNode && argNode.type === NODE_TYPES.OBJECT_EXPRESSION) {
174
+ // Extract detailed properties from object expression
175
+ properties[paramName] = {
176
+ type: 'object',
177
+ properties: extractProperties(argNode)
178
+ };
179
+ } else {
180
+ // For non-object arguments, use simple type inference
181
+ properties[paramName] = {
182
+ type: inferNodeValueType(argNode)
183
+ };
184
+ }
185
+ }
186
+ }
187
+
134
188
  // Special handling for Snowplow: remove 'action' from properties
135
189
  if (source === 'snowplow' && properties.action) {
136
190
  delete properties.action;
@@ -149,13 +203,17 @@ function processEventData(eventData, source, filePath, line, functionName) {
149
203
  /**
150
204
  * Gets string value from an AST node
151
205
  * @param {Object} node - AST node
206
+ * @param {Object} constantMap - Collected constant map
152
207
  * @returns {string|null} String value or null
153
208
  */
154
- function getStringValue(node) {
209
+ function getStringValue(node, constantMap = {}) {
155
210
  if (!node) return null;
156
211
  if (node.type === NODE_TYPES.LITERAL && typeof node.value === 'string') {
157
212
  return node.value;
158
213
  }
214
+ if (node.type === NODE_TYPES.MEMBER_EXPRESSION) {
215
+ return resolveMemberExpressionToString(node, constantMap);
216
+ }
159
217
  return null;
160
218
  }
161
219
 
@@ -173,6 +231,45 @@ function findPropertyByKey(objectNode, key) {
173
231
  );
174
232
  }
175
233
 
234
+ /**
235
+ * Infers the type of a value from an AST node (simple heuristic)
236
+ * @param {Object} node - AST node
237
+ * @returns {string} inferred type
238
+ */
239
+ function inferNodeValueType(node) {
240
+ if (!node) return 'any';
241
+ switch (node.type) {
242
+ case NODE_TYPES.LITERAL:
243
+ return typeof node.value;
244
+ case NODE_TYPES.OBJECT_EXPRESSION:
245
+ return 'object';
246
+ case NODE_TYPES.ARRAY_EXPRESSION:
247
+ return 'array';
248
+ default:
249
+ return 'any';
250
+ }
251
+ }
252
+
253
+ // Helper to resolve MemberExpression (CONST.KEY) to string using collected constant map
254
+ function resolveMemberExpressionToString(node, constantMap) {
255
+ if (!node || node.type !== NODE_TYPES.MEMBER_EXPRESSION) return null;
256
+ if (node.computed) return null; // Only support dot notation
257
+
258
+ const object = node.object;
259
+ const property = node.property;
260
+
261
+ if (object.type !== NODE_TYPES.IDENTIFIER) return null;
262
+ if (property.type !== NODE_TYPES.IDENTIFIER) return null;
263
+
264
+ const objName = object.name;
265
+ const propName = property.name;
266
+
267
+ if (constantMap && constantMap[objName] && typeof constantMap[objName][propName] === 'string') {
268
+ return constantMap[objName][propName];
269
+ }
270
+ return null;
271
+ }
272
+
176
273
  module.exports = {
177
274
  extractEventData,
178
275
  processEventData
@@ -11,16 +11,22 @@ const { parseFile, findTrackingEvents, FileReadError, ParseError } = require('./
11
11
  * @param {string} [customFunction] - Optional custom function name to detect
12
12
  * @returns {Array<Object>} Array of tracking events found in the file
13
13
  */
14
- function analyzeJsFile(filePath, customFunction) {
15
- const events = [];
16
-
14
+ function analyzeJsFile(filePath, customFunctionSignatures = null) {
17
15
  try {
18
- // Parse the file into an AST
16
+ // Parse the file into an AST once
19
17
  const ast = parseFile(filePath);
20
18
 
21
- // Find and extract tracking events
22
- const foundEvents = findTrackingEvents(ast, filePath, customFunction);
23
- events.push(...foundEvents);
19
+ // Single pass extraction covering built-in + all custom configs
20
+ const events = findTrackingEvents(ast, filePath, customFunctionSignatures || []);
21
+
22
+ // Deduplicate events (by source | eventName | line | functionName)
23
+ const unique = new Map();
24
+ for (const evt of events) {
25
+ const key = `${evt.source}|${evt.eventName}|${evt.line}|${evt.functionName}`;
26
+ if (!unique.has(key)) unique.set(key, evt);
27
+ }
28
+
29
+ return Array.from(unique.values());
24
30
 
25
31
  } catch (error) {
26
32
  if (error instanceof FileReadError) {
@@ -32,7 +38,7 @@ function analyzeJsFile(filePath, customFunction) {
32
38
  }
33
39
  }
34
40
 
35
- return events;
41
+ return [];
36
42
  }
37
43
 
38
44
  module.exports = { analyzeJsFile };
@@ -66,22 +66,147 @@ function parseFile(filePath) {
66
66
  }
67
67
  }
68
68
 
69
+ // ---------------------------------------------
70
+ // Helper – custom function matcher
71
+ // ---------------------------------------------
72
+
73
+ /**
74
+ * Determines whether a CallExpression node matches the provided custom function name.
75
+ * Supports both simple identifiers (e.g. myTrack) and dot-separated members (e.g. Custom.track).
76
+ * The logic mirrors isCustomFunction from detectors/analytics-source.js but is kept local to avoid
77
+ * circular dependencies.
78
+ * @param {Object} node – CallExpression AST node
79
+ * @param {string} fnName – Custom function name (could include dots)
80
+ * @returns {boolean}
81
+ */
82
+ function nodeMatchesCustomFunction(node, fnName) {
83
+ if (!fnName || !node.callee) return false;
84
+
85
+ const parts = fnName.split('.');
86
+
87
+ // Simple identifier case
88
+ if (parts.length === 1) {
89
+ return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === fnName;
90
+ }
91
+
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
98
+ let currentNode = node.callee;
99
+ let idx = parts.length - 1;
100
+
101
+ while (currentNode && idx >= 0) {
102
+ const expected = parts[idx];
103
+
104
+ if (currentNode.type === NODE_TYPES.MEMBER_EXPRESSION) {
105
+ if (
106
+ currentNode.property.type !== NODE_TYPES.IDENTIFIER ||
107
+ currentNode.property.name !== expected
108
+ ) {
109
+ return false;
110
+ }
111
+ currentNode = currentNode.object;
112
+ idx -= 1;
113
+ } else if (currentNode.type === NODE_TYPES.IDENTIFIER) {
114
+ return idx === 0 && currentNode.name === expected;
115
+ } else {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ return false;
121
+ }
122
+
123
+ // -----------------------------------------------------------------------------
124
+ // Utility – collect constants defined as plain objects or Object.freeze({...})
125
+ // -----------------------------------------------------------------------------
126
+ function collectConstantStringMap(ast) {
127
+ const map = {};
128
+
129
+ walk.simple(ast, {
130
+ VariableDeclaration(node) {
131
+ // Only consider const declarations
132
+ if (node.kind !== 'const') return;
133
+ node.declarations.forEach(decl => {
134
+ if (decl.id.type !== NODE_TYPES.IDENTIFIER || !decl.init) return;
135
+ const name = decl.id.name;
136
+ let objLiteral = null;
137
+
138
+ if (decl.init.type === NODE_TYPES.OBJECT_EXPRESSION) {
139
+ objLiteral = decl.init;
140
+ } else if (decl.init.type === NODE_TYPES.CALL_EXPRESSION) {
141
+ // Check for Object.freeze({...})
142
+ const callee = decl.init.callee;
143
+ if (
144
+ callee &&
145
+ callee.type === NODE_TYPES.MEMBER_EXPRESSION &&
146
+ callee.object.type === NODE_TYPES.IDENTIFIER &&
147
+ callee.object.name === 'Object' &&
148
+ callee.property.type === NODE_TYPES.IDENTIFIER &&
149
+ callee.property.name === 'freeze' &&
150
+ decl.init.arguments.length > 0 &&
151
+ decl.init.arguments[0].type === NODE_TYPES.OBJECT_EXPRESSION
152
+ ) {
153
+ objLiteral = decl.init.arguments[0];
154
+ }
155
+ }
156
+
157
+ if (objLiteral) {
158
+ map[name] = {};
159
+ objLiteral.properties.forEach(prop => {
160
+ if (!prop.key || !prop.value) return;
161
+ const keyName = prop.key.name || prop.key.value;
162
+ if (prop.value.type === NODE_TYPES.LITERAL && typeof prop.value.value === 'string') {
163
+ map[name][keyName] = prop.value.value;
164
+ }
165
+ });
166
+ }
167
+ });
168
+ }
169
+ });
170
+
171
+ return map;
172
+ }
173
+
69
174
  /**
70
- * Walks the AST and finds analytics tracking calls
71
- * @param {Object} ast - Parsed AST
72
- * @param {string} filePath - Path to the file being analyzed
73
- * @param {string} [customFunction] - Custom function name to detect
74
- * @returns {Array<Object>} Array of found events
175
+ * Walk the AST once and find tracking events for built-in providers plus any number of custom
176
+ * function configurations. This avoids the previous O(n * customConfigs) behaviour.
177
+ *
178
+ * @param {Object} ast – Parsed AST of the source file
179
+ * @param {string} filePath – Absolute/relative path to the source file
180
+ * @param {Object[]} [customConfigs=[]] – Array of parsed custom function configurations
181
+ * @returns {Array<Object>} – List of extracted tracking events
75
182
  */
76
- function findTrackingEvents(ast, filePath, customFunction) {
183
+ function findTrackingEvents(ast, filePath, customConfigs = []) {
77
184
  const events = [];
78
185
 
186
+ // Collect constant mappings once per file
187
+ const constantMap = collectConstantStringMap(ast);
188
+
79
189
  walk.ancestor(ast, {
80
190
  [NODE_TYPES.CALL_EXPRESSION]: (node, ancestors) => {
81
191
  try {
82
- const event = extractTrackingEvent(node, ancestors, filePath, customFunction);
83
- if (event) {
84
- events.push(event);
192
+ let matchedCustomConfig = null;
193
+
194
+ // Attempt to match any custom function first to avoid mis-classifying built-in providers
195
+ if (Array.isArray(customConfigs) && customConfigs.length > 0) {
196
+ for (const cfg of customConfigs) {
197
+ if (cfg && nodeMatchesCustomFunction(node, cfg.functionName)) {
198
+ matchedCustomConfig = cfg;
199
+ break;
200
+ }
201
+ }
202
+ }
203
+
204
+ if (matchedCustomConfig) {
205
+ const event = extractTrackingEvent(node, ancestors, filePath, constantMap, matchedCustomConfig);
206
+ if (event) events.push(event);
207
+ } else {
208
+ const event = extractTrackingEvent(node, ancestors, filePath, constantMap, null);
209
+ if (event) events.push(event);
85
210
  }
86
211
  } catch (error) {
87
212
  console.error(`Error processing node in ${filePath}:`, error.message);
@@ -97,25 +222,19 @@ function findTrackingEvents(ast, filePath, customFunction) {
97
222
  * @param {Object} node - CallExpression node
98
223
  * @param {Array<Object>} ancestors - Ancestor nodes
99
224
  * @param {string} filePath - File path
100
- * @param {string} [customFunction] - Custom function name
225
+ * @param {Object} constantMap - Constant string map
226
+ * @param {Object} [customConfig] - Custom function configuration object
101
227
  * @returns {Object|null} Extracted event or null
102
228
  */
103
- function extractTrackingEvent(node, ancestors, filePath, customFunction) {
104
- // Detect the analytics source
105
- const source = detectAnalyticsSource(node, customFunction);
229
+ function extractTrackingEvent(node, ancestors, filePath, constantMap, customConfig) {
230
+ const source = detectAnalyticsSource(node, customConfig?.functionName);
106
231
  if (source === 'unknown') {
107
232
  return null;
108
233
  }
109
-
110
- // Extract event data based on the source
111
- const eventData = extractEventData(node, source);
112
-
113
- // Get location and context information
234
+ const eventData = extractEventData(node, source, constantMap, customConfig);
114
235
  const line = node.loc.start.line;
115
236
  const functionName = findWrappingFunction(node, ancestors);
116
-
117
- // Process the event data into final format
118
- return processEventData(eventData, source, filePath, line, functionName);
237
+ return processEventData(eventData, source, filePath, line, functionName, customConfig);
119
238
  }
120
239
 
121
240
  module.exports = {
@@ -40,7 +40,7 @@ async function initPyodide() {
40
40
  * libraries, extracting event names, properties, and metadata.
41
41
  *
42
42
  * @param {string} filePath - Path to the Python file to analyze
43
- * @param {string} [customFunction=null] - Name of a custom tracking function to detect
43
+ * @param {string} [customFunctionSignature=null] - Signature of a custom tracking function to detect
44
44
  * @returns {Promise<Array<Object>>} Array of tracking events found in the file
45
45
  * @returns {Promise<Array>} Empty array if an error occurs
46
46
  *
@@ -52,48 +52,54 @@ async function initPyodide() {
52
52
  * // With custom tracking function
53
53
  * const events = await analyzePythonFile('./app.py', 'track_event');
54
54
  */
55
- async function analyzePythonFile(filePath, customFunction = null) {
55
+ async function analyzePythonFile(filePath, customFunctionSignatures = null) {
56
56
  // Validate inputs
57
57
  if (!filePath || typeof filePath !== 'string') {
58
58
  console.error('Invalid file path provided');
59
59
  return [];
60
60
  }
61
61
 
62
- try {
63
- // Check if file exists before reading
64
- if (!fs.existsSync(filePath)) {
65
- console.error(`File not found: ${filePath}`);
66
- return [];
67
- }
62
+ // Check if file exists before reading
63
+ if (!fs.existsSync(filePath)) {
64
+ console.error(`File not found: ${filePath}`);
65
+ return [];
66
+ }
68
67
 
69
- // Read the Python file
68
+ try {
69
+ // Read the Python file only once
70
70
  const code = fs.readFileSync(filePath, 'utf8');
71
-
71
+
72
72
  // Initialize Pyodide if not already done
73
73
  const py = await initPyodide();
74
-
75
- // Load the Python analyzer code
74
+
75
+ // Load the Python analyzer code (idempotent – redefining functions is fine)
76
76
  const analyzerPath = path.join(__dirname, 'pythonTrackingAnalyzer.py');
77
77
  if (!fs.existsSync(analyzerPath)) {
78
78
  throw new Error(`Python analyzer not found at: ${analyzerPath}`);
79
79
  }
80
-
81
80
  const analyzerCode = fs.readFileSync(analyzerPath, 'utf8');
82
-
83
- // Set up Python environment with necessary variables
84
- py.globals.set('code', code);
85
- py.globals.set('filepath', filePath);
86
- py.globals.set('custom_function', customFunction);
87
- // Set __name__ to null to prevent execution of main block
81
+ // Prevent the analyzer from executing any __main__ blocks that expect CLI usage
88
82
  py.globals.set('__name__', null);
89
-
90
- // Load and run the analyzer
91
83
  py.runPython(analyzerCode);
92
-
93
- // Execute the analysis and parse results
94
- const result = py.runPython('analyze_python_code(code, filepath, custom_function)');
95
- const events = JSON.parse(result);
96
-
84
+
85
+ // Helper to run analysis with a given custom config (can be null)
86
+ const runAnalysis = (customConfig) => {
87
+ py.globals.set('code', code);
88
+ py.globals.set('filepath', filePath);
89
+ py.globals.set('custom_config_json', customConfig ? JSON.stringify(customConfig) : null);
90
+ py.runPython('import json');
91
+ py.runPython('custom_config = None if custom_config_json == None else json.loads(custom_config_json)');
92
+ const result = py.runPython('analyze_python_code(code, filepath, custom_config)');
93
+ return JSON.parse(result);
94
+ };
95
+
96
+ // Prepare config argument (array or null)
97
+ const configArg = Array.isArray(customFunctionSignatures) && customFunctionSignatures.length > 0
98
+ ? customFunctionSignatures
99
+ : null;
100
+
101
+ const events = runAnalysis(configArg);
102
+
97
103
  return events;
98
104
  } catch (error) {
99
105
  // Log detailed error information for debugging