@flisk/analyze-tracking 0.8.4 → 0.8.5

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.8.4",
3
+ "version": "0.8.5",
4
4
  "description": "Analyzes tracking code in a project and generates data schemas",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -4,17 +4,36 @@
4
4
  */
5
5
 
6
6
  const path = require('path');
7
+ const { execSync } = require('child_process');
7
8
 
8
9
  const { parseCustomFunctionSignature } = require('./utils/customFunctionParser');
9
10
  const { getAllFiles } = require('../utils/fileProcessor');
10
11
  const { analyzeJsFile } = require('./javascript');
11
12
  const { analyzeTsFiles } = require('./typescript');
12
13
  const { analyzePythonFile } = require('./python');
13
- const { analyzeRubyFile } = require('./ruby');
14
+ const { analyzeRubyFile, prebuildConstantMaps } = require('./ruby');
14
15
  const { analyzeGoFile } = require('./go');
15
16
 
17
+ /**
18
+ * Analyzes a single file for analytics tracking calls
19
+ *
20
+ * Note: typescript files are handled separately by analyzeTsFiles, which is a batch processor
21
+ *
22
+ * @param {string} file - Path to the file to analyze
23
+ * @param {Array<string>} customFunctionSignatures - Custom function signatures to detect
24
+ * @returns {Promise<Array<Object>>} Array of events found in the file
25
+ */
26
+ async function analyzeFile(file, customFunctionSignatures) {
27
+ if (/\.jsx?$/.test(file)) return analyzeJsFile(file, customFunctionSignatures)
28
+ if (/\.py$/.test(file)) return analyzePythonFile(file, customFunctionSignatures)
29
+ if (/\.rb$/.test(file)) return analyzeRubyFile(file, customFunctionSignatures)
30
+ if (/\.go$/.test(file)) return analyzeGoFile(file, customFunctionSignatures)
31
+ return []
32
+ }
33
+
16
34
  /**
17
35
  * Adds an event to the events collection, merging properties if event already exists
36
+ *
18
37
  * @param {Object} allEvents - Collection of all events
19
38
  * @param {Object} event - Event to add
20
39
  * @param {string} baseDir - Base directory for relative path calculation
@@ -44,37 +63,72 @@ function addEventToCollection(allEvents, event, baseDir) {
44
63
  }
45
64
 
46
65
  /**
47
- * Processes all files that are not TypeScript files
66
+ * Processes all files that are not TypeScript files in parallel
67
+ *
68
+ * Checks the system's file descriptor limit and uses 80% of it to avoid running out of file descriptors
69
+ * Creates a promise pool and launches one analysis for each file in parallel
70
+ * When a slot frees up, the next file is launched
71
+ * Waits for the remaining work to complete
72
+ *
48
73
  * @param {Array<string>} files - Array of file paths
49
74
  * @param {Object} allEvents - Collection to add events to
50
75
  * @param {string} baseDir - Base directory for relative paths
51
76
  * @param {Array} customFunctionSignatures - Custom function signatures to detect
52
77
  */
53
78
  async function processFiles(files, allEvents, baseDir, customFunctionSignatures) {
54
- for (const file of files) {
55
- let events = [];
56
-
57
- const isJsFile = /\.(jsx?)$/.test(file);
58
- const isPythonFile = /\.(py)$/.test(file);
59
- const isRubyFile = /\.(rb)$/.test(file);
60
- const isGoFile = /\.(go)$/.test(file);
61
-
62
- if (isJsFile) {
63
- events = analyzeJsFile(file, customFunctionSignatures);
64
- } else if (isPythonFile) {
65
- events = await analyzePythonFile(file, customFunctionSignatures);
66
- } else if (isRubyFile) {
67
- events = await analyzeRubyFile(file, customFunctionSignatures);
68
- } else if (isGoFile) {
69
- events = await analyzeGoFile(file, customFunctionSignatures);
70
- } else {
71
- continue; // Skip unsupported file types
79
+ // Default concurrency limit
80
+ let concurrencyLimit = 64;
81
+
82
+ // Detect soft file descriptor limit from the system using `ulimit -n` (POSIX shells)
83
+ try {
84
+ const stdout = execSync('sh -c "ulimit -n"', { encoding: 'utf8' }).trim();
85
+ if (stdout !== 'unlimited') {
86
+ const limit = parseInt(stdout, 10);
87
+ if (!Number.isNaN(limit) && limit > 0) {
88
+ // Use 80% of the limit to keep head-room for other descriptors
89
+ concurrencyLimit = Math.max(4, Math.floor(limit * 0.8));
90
+ }
72
91
  }
92
+ } catch (_) {}
73
93
 
74
- events.forEach(event => addEventToCollection(allEvents, event, baseDir));
94
+ let next = 0; // index of the next file to start
95
+ const inFlight = new Set(); // promises currently running
96
+
97
+ // helper: launch one analysis and wire bookkeeping
98
+ const launch = (file) => {
99
+ const p = analyzeFile(file, customFunctionSignatures)
100
+ .then((events) => {
101
+ if (events) events.forEach(e => addEventToCollection(allEvents, e, baseDir))
102
+ })
103
+ .finally(() => inFlight.delete(p));
104
+ inFlight.add(p);
75
105
  }
106
+
107
+ // prime the pool
108
+ while (next < Math.min(concurrencyLimit, files.length)) {
109
+ launch(files[next++]);
110
+ }
111
+
112
+ // whenever a slot frees up, start the next file
113
+ while (next < files.length) {
114
+ await Promise.race(inFlight); // wait for one to finish
115
+ launch(files[next++]); // and immediately fill the slot
116
+ }
117
+
118
+ // wait for the remaining work
119
+ await Promise.all(inFlight);
76
120
  }
77
121
 
122
+ /**
123
+ * Analyze a directory recursively for analytics tracking calls
124
+ *
125
+ * This function scans all supported files in a directory tree and identifies analytics tracking calls,
126
+ * handling different file types appropriately.
127
+ *
128
+ * @param {string} dirPath - Path to the directory to analyze
129
+ * @param {Array<string>} [customFunctions=null] - Array of custom tracking function signatures to detect
130
+ * @returns {Promise<Object>} Object mapping event names to their tracking implementations
131
+ */
78
132
  async function analyzeDirectory(dirPath, customFunctions) {
79
133
  const allEvents = {};
80
134
 
@@ -86,19 +140,28 @@ async function analyzeDirectory(dirPath, customFunctions) {
86
140
 
87
141
  // Separate TypeScript files from others for optimized processing
88
142
  const tsFiles = [];
89
- const otherFiles = [];
143
+ const nonTsFiles = [];
144
+ const rubyFiles = [];
90
145
 
91
146
  for (const file of files) {
92
147
  const isTsFile = /\.(tsx?)$/.test(file);
93
148
  if (isTsFile) {
94
149
  tsFiles.push(file);
95
150
  } else {
96
- otherFiles.push(file);
151
+ nonTsFiles.push(file);
152
+ if (/\.rb$/.test(file)) {
153
+ rubyFiles.push(file);
154
+ }
97
155
  }
98
156
  }
99
157
 
158
+ // Prebuild constant maps for all Ruby directories to ensure constant resolution across files
159
+ if (rubyFiles.length > 0) {
160
+ await prebuildConstantMaps(rubyFiles);
161
+ }
162
+
100
163
  // First process non-TypeScript files
101
- await processFiles(otherFiles, allEvents, dirPath, customFunctionSignatures);
164
+ await processFiles(nonTsFiles, allEvents, dirPath, customFunctionSignatures);
102
165
 
103
166
  // Process TypeScript files with optimized batch processing
104
167
  if (tsFiles.length > 0) {
@@ -161,11 +161,18 @@ function extractCustomEvent(node, constantMap, customConfig) {
161
161
  function processEventData(eventData, source, filePath, line, functionName, customConfig) {
162
162
  const { eventName, propertiesNode } = eventData;
163
163
 
164
- if (!eventName || !propertiesNode || propertiesNode.type !== NODE_TYPES.OBJECT_EXPRESSION) {
164
+ // Must at least have an event name – properties are optional.
165
+ if (!eventName) {
165
166
  return null;
166
167
  }
167
168
 
168
- let properties = extractProperties(propertiesNode);
169
+ // Default to empty properties when none are supplied.
170
+ let properties = {};
171
+
172
+ // Only attempt extraction when we have a literal object expression.
173
+ if (propertiesNode && propertiesNode.type === NODE_TYPES.OBJECT_EXPRESSION) {
174
+ properties = extractProperties(propertiesNode);
175
+ }
169
176
 
170
177
  // Handle custom extra params
171
178
  if (source === 'custom' && customConfig && eventData.extraArgs) {
@@ -12,16 +12,74 @@ const { NODE_TYPES } = require('../constants');
12
12
  * @returns {string} The function name or 'global' if not in a function
13
13
  */
14
14
  function findWrappingFunction(node, ancestors) {
15
+ const REACT_HOOKS = new Set([
16
+ 'useEffect',
17
+ 'useLayoutEffect',
18
+ 'useInsertionEffect',
19
+ 'useCallback',
20
+ 'useMemo',
21
+ 'useReducer',
22
+ 'useState',
23
+ 'useImperativeHandle',
24
+ 'useDeferredValue',
25
+ 'useTransition'
26
+ ]);
27
+
28
+ let hookName = null; // e.g. "useEffect" or "useCallback(handleFoo)"
29
+ let componentName = null;
30
+ let firstNonHookFunction = null;
31
+
15
32
  // Traverse ancestors from closest to furthest
16
33
  for (let i = ancestors.length - 1; i >= 0; i--) {
17
34
  const current = ancestors[i];
18
- const functionName = extractFunctionName(current, node, ancestors[i - 1]);
19
-
20
- if (functionName) {
21
- return functionName;
35
+
36
+ // Detect React hook call (CallExpression with Identifier callee)
37
+ if (!hookName && current.type === NODE_TYPES.CALL_EXPRESSION && current.callee && current.callee.type === NODE_TYPES.IDENTIFIER && REACT_HOOKS.has(current.callee.name)) {
38
+ hookName = current.callee.name; // store plain hook name; we'll format later if needed
39
+ }
40
+
41
+ // Existing logic to extract named function contexts
42
+ const fnName = extractFunctionName(current, node, ancestors[i - 1]);
43
+ if (fnName) {
44
+ if (REACT_HOOKS.has(stripParens(fnName.split('.')[0]))) {
45
+ // fnName itself is a hook signature like "useCallback(handleFoo)" or "useEffect()"
46
+ if (!hookName) hookName = fnName;
47
+ continue;
48
+ }
49
+
50
+ // First non-hook function up the tree is treated as component/container name
51
+ if (!componentName) {
52
+ componentName = fnName;
53
+ }
54
+
55
+ // Early exit when we already have both pieces
56
+ if (hookName && componentName) {
57
+ break;
58
+ }
59
+
60
+ // Save first non-hook function for fallback when no hook detected
61
+ if (!firstNonHookFunction) {
62
+ firstNonHookFunction = fnName;
63
+ }
22
64
  }
23
65
  }
24
-
66
+
67
+ // If we detected hook + component, compose them
68
+ if (hookName && componentName) {
69
+ const formattedHook = typeof hookName === 'string' && hookName.endsWith('()') ? hookName.slice(0, -2) : hookName;
70
+ return `${componentName}.${formattedHook}`;
71
+ }
72
+
73
+ // If only hook signature found (no component) – return the hook signature itself
74
+ if (hookName) {
75
+ return hookName;
76
+ }
77
+
78
+ // Fallbacks to previous behaviour
79
+ if (firstNonHookFunction) {
80
+ return firstNonHookFunction;
81
+ }
82
+
25
83
  return 'global';
26
84
  }
27
85
 
@@ -118,6 +176,13 @@ function isFunctionNode(node) {
118
176
  );
119
177
  }
120
178
 
179
+ /**
180
+ * Utility to strip trailing parens from simple hook signatures
181
+ */
182
+ function stripParens(name) {
183
+ return name.endsWith('()') ? name.slice(0, -2) : name;
184
+ }
185
+
121
186
  module.exports = {
122
187
  findWrappingFunction
123
188
  };
@@ -8,6 +8,19 @@ const path = require('path');
8
8
 
9
9
  // Singleton instance of Pyodide
10
10
  let pyodide = null;
11
+ // Cache indicator to ensure we load pythonTrackingAnalyzer.py only once per process
12
+ let pythonAnalyzerLoaded = false;
13
+
14
+ // Simple mutex to ensure calls into the single Pyodide interpreter are serialized
15
+ let pyodideLock = Promise.resolve();
16
+
17
+ async function withPyodide(callback) {
18
+ // Chain the callback onto the existing lock promise
19
+ const resultPromise = pyodideLock.then(callback, callback);
20
+ // Replace lock with a promise that resolves when current callback finishes
21
+ pyodideLock = resultPromise.then(() => {}, () => {});
22
+ return resultPromise;
23
+ }
11
24
 
12
25
  /**
13
26
  * Initialize Pyodide runtime lazily
@@ -69,36 +82,42 @@ async function analyzePythonFile(filePath, customFunctionSignatures = null) {
69
82
  // Read the Python file only once
70
83
  const code = fs.readFileSync(filePath, 'utf8');
71
84
 
72
- // Initialize Pyodide if not already done
73
- const py = await initPyodide();
85
+ // All interaction with Pyodide must be serialized to avoid race conditions
86
+ const events = await withPyodide(async () => {
87
+ // Initialize Pyodide if not already done
88
+ const py = await initPyodide();
74
89
 
75
- // Load the Python analyzer code (idempotent redefining functions is fine)
76
- const analyzerPath = path.join(__dirname, 'pythonTrackingAnalyzer.py');
77
- if (!fs.existsSync(analyzerPath)) {
78
- throw new Error(`Python analyzer not found at: ${analyzerPath}`);
79
- }
80
- const analyzerCode = fs.readFileSync(analyzerPath, 'utf8');
81
- // Prevent the analyzer from executing any __main__ blocks that expect CLI usage
82
- py.globals.set('__name__', null);
83
- py.runPython(analyzerCode);
90
+ // Load the analyzer definitions into the Pyodide runtime once
91
+ if (!pythonAnalyzerLoaded) {
92
+ const analyzerPath = path.join(__dirname, 'pythonTrackingAnalyzer.py');
93
+ if (!fs.existsSync(analyzerPath)) {
94
+ throw new Error(`Python analyzer not found at: ${analyzerPath}`);
95
+ }
96
+ const analyzerCode = fs.readFileSync(analyzerPath, 'utf8');
97
+ // Prevent the analyzer from executing any __main__ blocks that expect CLI usage
98
+ py.globals.set('__name__', null);
99
+ py.runPython(analyzerCode);
100
+ pythonAnalyzerLoaded = true;
101
+ }
84
102
 
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
- };
103
+ // Helper to run analysis with a given custom config (can be null)
104
+ const runAnalysis = (customConfig) => {
105
+ py.globals.set('code', code);
106
+ py.globals.set('filepath', filePath);
107
+ py.globals.set('custom_config_json', customConfig ? JSON.stringify(customConfig) : null);
108
+ py.runPython('import json');
109
+ py.runPython('custom_config = None if custom_config_json == None else json.loads(custom_config_json)');
110
+ const result = py.runPython('analyze_python_code(code, filepath, custom_config)');
111
+ return JSON.parse(result);
112
+ };
95
113
 
96
- // Prepare config argument (array or null)
97
- const configArg = Array.isArray(customFunctionSignatures) && customFunctionSignatures.length > 0
98
- ? customFunctionSignatures
99
- : null;
114
+ // Prepare config argument (array or null)
115
+ const configArg = Array.isArray(customFunctionSignatures) && customFunctionSignatures.length > 0
116
+ ? customFunctionSignatures
117
+ : null;
100
118
 
101
- const events = runAnalysis(configArg);
119
+ return runAnalysis(configArg);
120
+ });
102
121
 
103
122
  return events;
104
123
  } catch (error) {
@@ -105,7 +105,7 @@ async function extractEventName(node, source, customConfig = null, constantMap =
105
105
  * @param {Object} customConfig - Custom configuration for custom functions
106
106
  * @returns {Object|null} - The extracted properties or null
107
107
  */
108
- async function extractProperties(node, source, customConfig = null) {
108
+ async function extractProperties(node, source, customConfig = null, variableMap = null) {
109
109
  const { HashNode, ArrayNode } = await prismPromise;
110
110
 
111
111
  if (source === 'segment' || source === 'rudderstack') {
@@ -248,6 +248,20 @@ async function extractProperties(node, source, customConfig = null) {
248
248
  if (propsArg instanceof HashNode) {
249
249
  const hashProps = await extractHashProperties(propsArg);
250
250
  Object.assign(properties, hashProps);
251
+ } else {
252
+ // Attempt to resolve variable references (e.g., a local variable containing a hash)
253
+ const prism = await prismPromise;
254
+ const LocalVariableReadNode = prism.LocalVariableReadNode;
255
+
256
+ if (variableMap && LocalVariableReadNode && propsArg instanceof LocalVariableReadNode) {
257
+ const varName = propsArg.name;
258
+ if (variableMap[varName]) {
259
+ Object.assign(properties, variableMap[varName]);
260
+ }
261
+ } else if (variableMap && propsArg && typeof propsArg.name === 'string' && variableMap[propsArg.name]) {
262
+ // Fallback: match by variable name when node type isn't LocalVariableReadNode
263
+ Object.assign(properties, variableMap[propsArg.name]);
264
+ }
251
265
  }
252
266
 
253
267
  return Object.keys(properties).length > 0 ? properties : null;
@@ -7,6 +7,9 @@ const fs = require('fs');
7
7
  const path = require('path');
8
8
  const TrackingVisitor = require('./visitor');
9
9
 
10
+ // New: cache constant maps so we don't rebuild for every file in the same directory
11
+ const constantMapCache = {};
12
+
10
13
  // Lazy-loaded parse function from Ruby Prism
11
14
  let parse = null;
12
15
 
@@ -143,12 +146,21 @@ async function buildConstantMapForDirectory(directory) {
143
146
 
144
147
  if (!fs.existsSync(directory)) return constantMap;
145
148
 
149
+ // Return cached version if we've already built the map for this directory
150
+ if (constantMapCache[directory]) {
151
+ return constantMapCache[directory];
152
+ }
153
+
146
154
  const files = fs.readdirSync(directory).filter(f => f.endsWith('.rb'));
147
155
 
148
156
  for (const file of files) {
149
157
  const fullPath = path.join(directory, file);
150
158
  try {
151
159
  const content = fs.readFileSync(fullPath, 'utf8');
160
+ if (!parse) {
161
+ const { loadPrism } = await import('@ruby/prism');
162
+ parse = await loadPrism();
163
+ }
152
164
  const ast = await parse(content);
153
165
  await collectConstants(ast.value, [], constantMap);
154
166
  } catch (err) {
@@ -157,6 +169,8 @@ async function buildConstantMapForDirectory(directory) {
157
169
  }
158
170
  }
159
171
 
172
+ // Cache the result before returning so subsequent look-ups are instantaneous
173
+ constantMapCache[directory] = constantMap;
160
174
  return constantMap;
161
175
  }
162
176
 
@@ -182,6 +196,13 @@ async function analyzeRubyFile(filePath, customFunctionSignatures = null) {
182
196
  const currentDir = path.dirname(filePath);
183
197
  const constantMap = await buildConstantMapForDirectory(currentDir);
184
198
 
199
+ // Merge constants from all cached maps (to allow cross-directory resolution)
200
+ for (const dir in constantMapCache) {
201
+ if (dir !== currentDir) {
202
+ Object.assign(constantMap, constantMapCache[dir]);
203
+ }
204
+ }
205
+
185
206
  // Parse the Ruby code into an AST once
186
207
  let ast;
187
208
  try {
@@ -191,8 +212,14 @@ async function analyzeRubyFile(filePath, customFunctionSignatures = null) {
191
212
  return [];
192
213
  }
193
214
 
194
- // Single visitor pass covering all custom configs, with constant map for resolution
195
- const visitor = new TrackingVisitor(code, filePath, customFunctionSignatures || [], constantMap);
215
+ // Collect variable assignments to hash literals within the file
216
+ const variableMap = {};
217
+ try {
218
+ await collectVariableAssignments(ast.value, variableMap);
219
+ } catch (_) {}
220
+
221
+ // Single visitor pass covering all custom configs, with constant map and variable map for resolution
222
+ const visitor = new TrackingVisitor(code, filePath, customFunctionSignatures || [], constantMap, variableMap);
196
223
  const events = await visitor.analyze(ast);
197
224
 
198
225
  // Deduplicate events
@@ -210,4 +237,73 @@ async function analyzeRubyFile(filePath, customFunctionSignatures = null) {
210
237
  }
211
238
  }
212
239
 
213
- module.exports = { analyzeRubyFile };
240
+ // New utility: collect local variable assignments that point to hash literals
241
+ async function collectVariableAssignments(node, variableMap) {
242
+ if (!node) return;
243
+
244
+ const prism = await import('@ruby/prism');
245
+ const LocalVariableWriteNode = prism.LocalVariableWriteNode;
246
+ const HashNode = prism.HashNode;
247
+ const CallNode = prism.CallNode;
248
+
249
+ if (LocalVariableWriteNode && node instanceof LocalVariableWriteNode) {
250
+ if (node.value instanceof HashNode) {
251
+ const varName = node.name;
252
+ // Reuse existing extractor to turn HashNode into properties object
253
+ const { extractHashProperties } = require('./extractors');
254
+ const props = await extractHashProperties(node.value);
255
+ variableMap[varName] = props;
256
+ } else if (node.value instanceof CallNode) {
257
+ // Handle patterns like { ... }.compact or { ... }.compact!
258
+ const callNode = node.value;
259
+ const methodName = callNode.name;
260
+
261
+ // Check if the call is a compact/compact! call with a Hash receiver
262
+ if ((methodName === 'compact' || methodName === 'compact!') && callNode.receiver instanceof HashNode) {
263
+ const varName = node.name;
264
+ const { extractHashProperties } = require('./extractors');
265
+ const props = await extractHashProperties(callNode.receiver);
266
+ variableMap[varName] = props;
267
+ }
268
+ }
269
+ }
270
+
271
+ // Recurse similarly to collectConstants generic traversal
272
+ const keys = Object.keys(node);
273
+ for (const key of keys) {
274
+ const val = node[key];
275
+ if (!val) continue;
276
+
277
+ const traverseChild = async (child) => {
278
+ if (child && typeof child === 'object' && (child.location || child.constructor?.name?.endsWith('Node'))) {
279
+ await collectVariableAssignments(child, variableMap);
280
+ }
281
+ };
282
+
283
+ if (Array.isArray(val)) {
284
+ for (const c of val) {
285
+ await traverseChild(c);
286
+ }
287
+ } else {
288
+ await traverseChild(val);
289
+ }
290
+ }
291
+ }
292
+
293
+ // Helper to prebuild constant maps for all discovered ruby directories in a project
294
+ async function prebuildConstantMaps(rubyFiles) {
295
+ const dirs = new Set(rubyFiles.map(f => path.dirname(f)));
296
+ for (const dir of dirs) {
297
+ try {
298
+ await buildConstantMapForDirectory(dir);
299
+ } catch (_) {
300
+ // ignore
301
+ }
302
+ }
303
+ }
304
+
305
+ module.exports = {
306
+ analyzeRubyFile,
307
+ buildConstantMapForDirectory,
308
+ prebuildConstantMaps
309
+ };
@@ -8,11 +8,12 @@ const { extractEventName, extractProperties } = require('./extractors');
8
8
  const { findWrappingFunction, traverseNode, getLineNumber } = require('./traversal');
9
9
 
10
10
  class TrackingVisitor {
11
- constructor(code, filePath, customConfigs = [], constantMap = {}) {
11
+ constructor(code, filePath, customConfigs = [], constantMap = {}, variableMap = {}) {
12
12
  this.code = code;
13
13
  this.filePath = filePath;
14
14
  this.customConfigs = Array.isArray(customConfigs) ? customConfigs : [];
15
15
  this.constantMap = constantMap || {};
16
+ this.variableMap = variableMap || {};
16
17
  this.events = [];
17
18
  }
18
19
 
@@ -48,16 +49,10 @@ class TrackingVisitor {
48
49
 
49
50
  const line = getLineNumber(this.code, node.location);
50
51
 
51
- // For module-scoped custom functions, use the custom function name as the functionName
52
- // For simple custom functions, use the wrapping function name
53
- let functionName;
54
- if (source === 'custom' && matchedConfig && matchedConfig.functionName.includes('.')) {
55
- functionName = matchedConfig.functionName;
56
- } else {
57
- functionName = await findWrappingFunction(node, ancestors);
58
- }
52
+ // Always use the enclosing method/block/global context for the function name
53
+ const functionName = await findWrappingFunction(node, ancestors);
59
54
 
60
- const properties = await extractProperties(node, source, matchedConfig);
55
+ const properties = await extractProperties(node, source, matchedConfig, this.variableMap);
61
56
 
62
57
  this.events.push({
63
58
  eventName,
@@ -186,26 +186,25 @@ function extractDefaultEvent(node, checker, sourceFile) {
186
186
  function processEventData(eventData, source, filePath, line, functionName, checker, sourceFile, customConfig) {
187
187
  const { eventName, propertiesNode } = eventData;
188
188
 
189
- if (!eventName || !propertiesNode) {
189
+ // Require an event name – properties are optional.
190
+ if (!eventName) {
190
191
  return null;
191
192
  }
192
193
 
193
- let properties = null;
194
+ let properties = {};
194
195
 
195
- // Check if properties is an object literal
196
- if (ts.isObjectLiteralExpression(propertiesNode)) {
197
- properties = extractProperties(checker, propertiesNode);
198
- }
199
- // Check if properties is an identifier (variable reference)
200
- else if (ts.isIdentifier(propertiesNode)) {
201
- const resolvedNode = resolveIdentifierToInitializer(checker, propertiesNode, sourceFile);
202
- if (resolvedNode && ts.isObjectLiteralExpression(resolvedNode)) {
203
- properties = extractProperties(checker, resolvedNode);
196
+ if (propertiesNode) {
197
+ // Check if properties is an object literal
198
+ if (ts.isObjectLiteralExpression(propertiesNode)) {
199
+ properties = extractProperties(checker, propertiesNode);
200
+ }
201
+ // Check if properties is an identifier (variable reference)
202
+ else if (ts.isIdentifier(propertiesNode)) {
203
+ const resolvedNode = resolveIdentifierToInitializer(checker, propertiesNode, sourceFile);
204
+ if (resolvedNode && ts.isObjectLiteralExpression(resolvedNode)) {
205
+ properties = extractProperties(checker, resolvedNode);
206
+ }
204
207
  }
205
- }
206
-
207
- if (!properties) {
208
- return null;
209
208
  }
210
209
 
211
210
  // Special handling for Snowplow: remove 'action' from properties
@@ -12,19 +12,61 @@ const { isReactHookCall } = require('./type-resolver');
12
12
  * @returns {string} The function name or 'global' if not in a function
13
13
  */
14
14
  function findWrappingFunction(node) {
15
+ // ts is already required at module scope
16
+ const REACT_HOOKS = new Set([
17
+ 'useEffect',
18
+ 'useLayoutEffect',
19
+ 'useInsertionEffect',
20
+ 'useCallback',
21
+ 'useMemo',
22
+ 'useReducer',
23
+ 'useState',
24
+ 'useImperativeHandle',
25
+ 'useDeferredValue',
26
+ 'useTransition'
27
+ ]);
28
+
15
29
  let current = node;
30
+ let hookSignature = null; // e.g. useEffect(), useCallback(handleFoo)
31
+ let componentName = null;
32
+ let firstNonHookFunction = null;
16
33
 
17
34
  while (current) {
18
- const functionName = extractFunctionName(current);
19
-
20
- if (functionName) {
21
- return functionName;
35
+ const fnName = extractFunctionName(current);
36
+
37
+ if (fnName) {
38
+ const baseName = fnName.split('(')[0].replace(/\s+/g, '');
39
+ const isHookSig = REACT_HOOKS.has(baseName);
40
+
41
+ if (isHookSig && !hookSignature) {
42
+ hookSignature = fnName; // capture complete signature (may include params)
43
+ // Continue searching upward for component
44
+ } else if (!isHookSig && !componentName) {
45
+ componentName = fnName;
46
+ if (hookSignature) {
47
+ break; // we have both
48
+ }
49
+ }
50
+
51
+ if (!firstNonHookFunction) {
52
+ firstNonHookFunction = fnName;
53
+ }
22
54
  }
23
55
 
24
56
  current = current.parent;
25
57
  }
26
58
 
27
- return 'global';
59
+ if (hookSignature && componentName) {
60
+ // Remove trailing () for useEffect etc
61
+ const formattedHook = hookSignature.endsWith('()') ? hookSignature.slice(0, -2) : hookSignature;
62
+ return `${componentName}.${formattedHook}`;
63
+ }
64
+
65
+ if (hookSignature) {
66
+ return hookSignature;
67
+ }
68
+
69
+ return firstNonHookFunction || 'global';
28
70
  }
29
71
 
30
72
  /**