@flisk/analyze-tracking 0.8.3 → 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.3",
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,73 +4,169 @@
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
- const { analyzeTsFile } = require('./typescript');
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
+
34
+ /**
35
+ * Adds an event to the events collection, merging properties if event already exists
36
+ *
37
+ * @param {Object} allEvents - Collection of all events
38
+ * @param {Object} event - Event to add
39
+ * @param {string} baseDir - Base directory for relative path calculation
40
+ */
41
+ function addEventToCollection(allEvents, event, baseDir) {
42
+ const relativeFilePath = path.relative(baseDir, event.filePath);
43
+
44
+ const implementation = {
45
+ path: relativeFilePath,
46
+ line: event.line,
47
+ function: event.functionName,
48
+ destination: event.source
49
+ };
50
+
51
+ if (!allEvents[event.eventName]) {
52
+ allEvents[event.eventName] = {
53
+ implementations: [implementation],
54
+ properties: event.properties,
55
+ };
56
+ } else {
57
+ allEvents[event.eventName].implementations.push(implementation);
58
+ allEvents[event.eventName].properties = {
59
+ ...allEvents[event.eventName].properties,
60
+ ...event.properties,
61
+ };
62
+ }
63
+ }
64
+
65
+ /**
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
+ *
73
+ * @param {Array<string>} files - Array of file paths
74
+ * @param {Object} allEvents - Collection to add events to
75
+ * @param {string} baseDir - Base directory for relative paths
76
+ * @param {Array} customFunctionSignatures - Custom function signatures to detect
77
+ */
78
+ async function processFiles(files, allEvents, baseDir, customFunctionSignatures) {
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
+ }
91
+ }
92
+ } catch (_) {}
93
+
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);
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);
120
+ }
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
+ */
16
132
  async function analyzeDirectory(dirPath, customFunctions) {
17
133
  const allEvents = {};
18
134
 
19
- const customFunctionSignatures = (customFunctions && customFunctions?.length > 0) ? customFunctions.map(parseCustomFunctionSignature) : null;
135
+ const customFunctionSignatures = (customFunctions?.length > 0)
136
+ ? customFunctions.map(parseCustomFunctionSignature)
137
+ : null;
20
138
 
21
139
  const files = getAllFiles(dirPath);
22
-
140
+
141
+ // Separate TypeScript files from others for optimized processing
142
+ const tsFiles = [];
143
+ const nonTsFiles = [];
144
+ const rubyFiles = [];
145
+
23
146
  for (const file of files) {
24
- let events = [];
25
-
26
- const isJsFile = /\.(jsx?)$/.test(file);
27
147
  const isTsFile = /\.(tsx?)$/.test(file);
28
- const isPythonFile = /\.(py)$/.test(file);
29
- const isRubyFile = /\.(rb)$/.test(file);
30
- const isGoFile = /\.(go)$/.test(file);
31
-
32
- if (isJsFile) {
33
- events = analyzeJsFile(file, customFunctionSignatures);
34
- } else if (isTsFile) {
35
- // Pass null program so analyzeTsFile will create a per-file program using the file's nearest tsconfig.json
36
- events = analyzeTsFile(file, null, customFunctionSignatures);
37
- } else if (isPythonFile) {
38
- events = await analyzePythonFile(file, customFunctionSignatures);
39
- } else if (isRubyFile) {
40
- events = await analyzeRubyFile(file, customFunctionSignatures);
41
- } else if (isGoFile) {
42
- events = await analyzeGoFile(file, customFunctionSignatures);
148
+ if (isTsFile) {
149
+ tsFiles.push(file);
43
150
  } else {
44
- continue;
151
+ nonTsFiles.push(file);
152
+ if (/\.rb$/.test(file)) {
153
+ rubyFiles.push(file);
154
+ }
45
155
  }
156
+ }
46
157
 
47
- events.forEach((event) => {
48
- const relativeFilePath = path.relative(dirPath, event.filePath);
49
-
50
- if (!allEvents[event.eventName]) {
51
- allEvents[event.eventName] = {
52
- implementations: [{
53
- path: relativeFilePath,
54
- line: event.line,
55
- function: event.functionName,
56
- destination: event.source
57
- }],
58
- properties: event.properties,
59
- };
60
- } else {
61
- allEvents[event.eventName].implementations.push({
62
- path: relativeFilePath,
63
- line: event.line,
64
- function: event.functionName,
65
- destination: event.source
66
- });
67
-
68
- allEvents[event.eventName].properties = {
69
- ...allEvents[event.eventName].properties,
70
- ...event.properties,
71
- };
72
- }
73
- });
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
+
163
+ // First process non-TypeScript files
164
+ await processFiles(nonTsFiles, allEvents, dirPath, customFunctionSignatures);
165
+
166
+ // Process TypeScript files with optimized batch processing
167
+ if (tsFiles.length > 0) {
168
+ const tsEvents = analyzeTsFiles(tsFiles, customFunctionSignatures);
169
+ tsEvents.forEach(event => addEventToCollection(allEvents, event, dirPath));
74
170
  }
75
171
 
76
172
  return allEvents;
@@ -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
@@ -3,40 +3,130 @@
3
3
  * @module analyze/typescript
4
4
  */
5
5
 
6
- const { getProgram, findTrackingEvents, ProgramError, SourceFileError } = require('./parser');
6
+ const { getProgram, findTrackingEvents, ProgramError, SourceFileError, DEFAULT_COMPILER_OPTIONS } = require('./parser');
7
+ const ts = require('typescript');
8
+ const path = require('path');
9
+
10
+ /**
11
+ * Creates a standalone TypeScript program for a single file
12
+ * This is used as a fallback when the main program can't resolve the file
13
+ * @param {string} filePath - Path to the TypeScript file
14
+ * @returns {Object} TypeScript program
15
+ */
16
+ function createStandaloneProgram(filePath) {
17
+ const compilerOptions = {
18
+ ...DEFAULT_COMPILER_OPTIONS,
19
+ // We intentionally allow module resolution here so that imported constants
20
+ // (e.g. event name strings defined in a sibling file) can be followed by the
21
+ // TypeScript compiler.
22
+ isolatedModules: true
23
+ };
24
+
25
+ return ts.createProgram([filePath], compilerOptions);
26
+ }
27
+
28
+ /**
29
+ * Deduplicates events based on source, eventName, line, and functionName
30
+ * @param {Array<Object>} events - Array of events to deduplicate
31
+ * @returns {Array<Object>} Deduplicated events
32
+ */
33
+ function deduplicateEvents(events) {
34
+ const uniqueEvents = new Map();
35
+
36
+ for (const event of events) {
37
+ const key = `${event.source}|${event.eventName}|${event.line}|${event.functionName}`;
38
+ if (!uniqueEvents.has(key)) {
39
+ uniqueEvents.set(key, event);
40
+ }
41
+ }
42
+
43
+ return Array.from(uniqueEvents.values());
44
+ }
45
+
46
+ /**
47
+ * Attempts to analyze a file using a standalone program as fallback
48
+ * @param {string} filePath - Path to the TypeScript file
49
+ * @param {Array} customFunctionSignatures - Custom function signatures to detect
50
+ * @returns {Array<Object>} Array of events or empty array if failed
51
+ */
52
+ function tryStandaloneAnalysis(filePath, customFunctionSignatures) {
53
+ try {
54
+ console.warn(`Unable to resolve ${filePath} in main program. Attempting standalone analysis.`);
55
+
56
+ const standaloneProgram = createStandaloneProgram(filePath);
57
+ const sourceFile = standaloneProgram.getSourceFile(filePath);
58
+
59
+ if (!sourceFile) {
60
+ console.warn(`Standalone analysis failed: could not get source file for ${filePath}`);
61
+ return [];
62
+ }
63
+
64
+ const checker = standaloneProgram.getTypeChecker();
65
+ const events = findTrackingEvents(sourceFile, checker, filePath, customFunctionSignatures || []);
66
+
67
+ return deduplicateEvents(events);
68
+ } catch (standaloneError) {
69
+ console.warn(`Standalone analysis failed for ${filePath}: ${standaloneError.message}`);
70
+ return [];
71
+ }
72
+ }
73
+
74
+ /**
75
+ * Gets or creates a cached TypeScript program for efficient reuse
76
+ * @param {string} filePath - Path to the TypeScript file
77
+ * @param {Map} programCache - Map of tsconfig paths to programs
78
+ * @returns {Object} TypeScript program
79
+ */
80
+ function getCachedTsProgram(filePath, programCache) {
81
+ // Locate nearest tsconfig.json (may be undefined)
82
+ const searchPath = path.dirname(filePath);
83
+ const configPath = ts.findConfigFile(searchPath, ts.sys.fileExists, 'tsconfig.json');
84
+
85
+ // We only cache when a tsconfig.json exists because the resulting program
86
+ // represents an entire project. If no config is present we build a
87
+ // stand-alone program that should not be reused for other files – otherwise
88
+ // later files would be missing from the program (which is precisely what
89
+ // caused the regression we are fixing).
90
+ const shouldCache = Boolean(configPath);
91
+ const cacheKey = configPath; // undefined when shouldCache is false
92
+
93
+ if (shouldCache && programCache.has(cacheKey)) {
94
+ return programCache.get(cacheKey);
95
+ }
96
+
97
+ const program = getProgram(filePath, null);
98
+
99
+ if (shouldCache) {
100
+ programCache.set(cacheKey, program);
101
+ }
102
+
103
+ return program;
104
+ }
7
105
 
8
106
  /**
9
107
  * Analyzes a TypeScript file for analytics tracking calls
10
108
  * @param {string} filePath - Path to the TypeScript file to analyze
11
109
  * @param {Object} [program] - Optional existing TypeScript program to reuse
12
- * @param {string} [customFunctionSignature] - Optional custom function signature to detect
110
+ * @param {Array} [customFunctionSignatures] - Optional custom function signatures to detect
13
111
  * @returns {Array<Object>} Array of tracking events found in the file
14
112
  */
15
113
  function analyzeTsFile(filePath, program = null, customFunctionSignatures = null) {
16
114
  try {
17
- // Get or create TypeScript program (only once)
115
+ // Get or create TypeScript program
18
116
  const tsProgram = getProgram(filePath, program);
19
117
 
20
118
  // Get source file from program
21
119
  const sourceFile = tsProgram.getSourceFile(filePath);
22
120
  if (!sourceFile) {
23
- throw new SourceFileError(filePath);
121
+ // Try standalone analysis as fallback
122
+ return tryStandaloneAnalysis(filePath, customFunctionSignatures);
24
123
  }
25
124
 
26
- // Get type checker
125
+ // Get type checker and find tracking events
27
126
  const checker = tsProgram.getTypeChecker();
28
-
29
- // Single-pass collection covering built-in + all custom configs
30
127
  const events = findTrackingEvents(sourceFile, checker, filePath, customFunctionSignatures || []);
31
128
 
32
- // Deduplicate events
33
- const unique = new Map();
34
- for (const evt of events) {
35
- const key = `${evt.source}|${evt.eventName}|${evt.line}|${evt.functionName}`;
36
- if (!unique.has(key)) unique.set(key, evt);
37
- }
38
-
39
- return Array.from(unique.values());
129
+ return deduplicateEvents(events);
40
130
 
41
131
  } catch (error) {
42
132
  if (error instanceof ProgramError) {
@@ -46,9 +136,37 @@ function analyzeTsFile(filePath, program = null, customFunctionSignatures = null
46
136
  } else {
47
137
  console.error(`Error analyzing TypeScript file ${filePath}: ${error.message}`);
48
138
  }
139
+
140
+ return [];
49
141
  }
142
+ }
50
143
 
51
- return [];
144
+ /**
145
+ * Analyzes multiple TypeScript files with program reuse for better performance
146
+ * @param {Array<string>} tsFiles - Array of TypeScript file paths
147
+ * @param {Array} customFunctionSignatures - Custom function signatures to detect
148
+ * @returns {Array<Object>} Array of all tracking events found across all files
149
+ */
150
+ function analyzeTsFiles(tsFiles, customFunctionSignatures) {
151
+ const allEvents = [];
152
+ const tsProgramCache = new Map(); // tsconfig path -> program
153
+
154
+ for (const file of tsFiles) {
155
+ try {
156
+ // Use cached program or create new one
157
+ const program = getCachedTsProgram(file, tsProgramCache);
158
+ const events = analyzeTsFile(file, program, customFunctionSignatures);
159
+
160
+ allEvents.push(...events);
161
+ } catch (error) {
162
+ console.warn(`Error processing TypeScript file ${file}: ${error.message}`);
163
+ }
164
+ }
165
+
166
+ return allEvents;
52
167
  }
53
168
 
54
- module.exports = { analyzeTsFile };
169
+ module.exports = {
170
+ analyzeTsFile,
171
+ analyzeTsFiles
172
+ };
@@ -32,6 +32,103 @@ class SourceFileError extends Error {
32
32
  }
33
33
  }
34
34
 
35
+ /**
36
+ * Default TypeScript compiler options for analysis
37
+ */
38
+ const DEFAULT_COMPILER_OPTIONS = {
39
+ target: ts.ScriptTarget.Latest,
40
+ module: ts.ModuleKind.CommonJS,
41
+ allowJs: true,
42
+ checkJs: false,
43
+ noEmit: true,
44
+ jsx: ts.JsxEmit.Preserve,
45
+ moduleResolution: ts.ModuleResolutionKind.NodeJs,
46
+ allowSyntheticDefaultImports: true,
47
+ esModuleInterop: true,
48
+ skipLibCheck: true
49
+ };
50
+
51
+ /**
52
+ * Maximum number of files to include in TypeScript program for performance
53
+ */
54
+ const MAX_FILES_THRESHOLD = 10000;
55
+
56
+ /**
57
+ * Attempts to parse tsconfig.json and extract compiler options and file names
58
+ * @param {string} configPath - Path to tsconfig.json
59
+ * @returns {Object|null} Parsed config with options and fileNames, or null if failed
60
+ */
61
+ function parseTsConfig(configPath) {
62
+ try {
63
+ const readResult = ts.readConfigFile(configPath, ts.sys.readFile);
64
+ if (readResult.error || !readResult.config) {
65
+ return null;
66
+ }
67
+
68
+ const parseResult = ts.parseJsonConfigFileContent(
69
+ readResult.config,
70
+ ts.sys,
71
+ path.dirname(configPath)
72
+ );
73
+
74
+ if (parseResult.errors && parseResult.errors.length > 0) {
75
+ return null;
76
+ }
77
+
78
+ return {
79
+ options: parseResult.options,
80
+ fileNames: parseResult.fileNames
81
+ };
82
+ } catch (error) {
83
+ console.warn(`Failed to parse tsconfig.json at ${configPath}. Error: ${error.message}`);
84
+ return null;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Determines the appropriate files to include in the TypeScript program
90
+ * @param {string} filePath - Target file path
91
+ * @param {string|null} configPath - Path to tsconfig.json if found
92
+ * @returns {Object} Configuration with compilerOptions and rootNames
93
+ */
94
+ function getProgramConfiguration(filePath, configPath) {
95
+ let compilerOptions = { ...DEFAULT_COMPILER_OPTIONS };
96
+ let rootNames = [filePath];
97
+
98
+ if (!configPath) {
99
+ return { compilerOptions, rootNames };
100
+ }
101
+
102
+ const config = parseTsConfig(configPath);
103
+ if (!config) {
104
+ console.warn(`Failed to parse tsconfig.json at ${configPath}. Analyzing ${filePath} in isolation.`);
105
+ return { compilerOptions, rootNames };
106
+ }
107
+
108
+ // Inherit compiler options from tsconfig
109
+ compilerOptions = { ...compilerOptions, ...config.options };
110
+
111
+ // Determine file inclusion strategy based on project size
112
+ const projectFileCount = config.fileNames.length;
113
+
114
+ if (projectFileCount > 0 && projectFileCount <= MAX_FILES_THRESHOLD) {
115
+ // Small to medium project: include all files for better type checking
116
+ rootNames = [...config.fileNames];
117
+ if (!rootNames.includes(filePath)) {
118
+ rootNames.push(filePath);
119
+ }
120
+ } else if (projectFileCount > MAX_FILES_THRESHOLD) {
121
+ // Large project: only include the target file to avoid performance issues
122
+ console.warn(
123
+ `Large TypeScript project detected (${projectFileCount} files). ` +
124
+ `Analyzing ${filePath} in isolation for performance.`
125
+ );
126
+ rootNames = [filePath];
127
+ }
128
+
129
+ return { compilerOptions, rootNames };
130
+ }
131
+
35
132
  /**
36
133
  * Gets or creates a TypeScript program for analysis
37
134
  * @param {string} filePath - Path to the TypeScript file
@@ -45,38 +142,15 @@ function getProgram(filePath, existingProgram) {
45
142
  }
46
143
 
47
144
  try {
48
- // Try to locate a tsconfig.json nearest to the file to inherit compiler options (important for path aliases)
145
+ // Find the nearest tsconfig.json
49
146
  const searchPath = path.dirname(filePath);
50
147
  const configPath = ts.findConfigFile(searchPath, ts.sys.fileExists, 'tsconfig.json');
51
148
 
52
- let compilerOptions = {
53
- target: ts.ScriptTarget.Latest,
54
- module: ts.ModuleKind.CommonJS,
55
- allowJs: true,
56
- checkJs: false,
57
- noEmit: true,
58
- jsx: ts.JsxEmit.Preserve
59
- };
60
- let rootNames = [filePath];
61
-
62
- if (configPath) {
63
- // Read and parse the tsconfig.json
64
- const readResult = ts.readConfigFile(configPath, ts.sys.readFile);
65
- if (!readResult.error && readResult.config) {
66
- const parseResult = ts.parseJsonConfigFileContent(
67
- readResult.config,
68
- ts.sys,
69
- path.dirname(configPath)
70
- );
71
- if (!parseResult.errors || parseResult.errors.length === 0) {
72
- compilerOptions = { ...compilerOptions, ...parseResult.options };
73
- rootNames = parseResult.fileNames.length > 0 ? parseResult.fileNames : rootNames;
74
- }
75
- }
76
- }
149
+ // Get program configuration
150
+ const { compilerOptions, rootNames } = getProgramConfiguration(filePath, configPath);
77
151
 
78
- const program = ts.createProgram(rootNames, compilerOptions);
79
- return program;
152
+ // Create and return the TypeScript program
153
+ return ts.createProgram(rootNames, compilerOptions);
80
154
  } catch (error) {
81
155
  throw new ProgramError(filePath, error);
82
156
  }
@@ -94,27 +168,37 @@ function findTrackingEvents(sourceFile, checker, filePath, customConfigs = []) {
94
168
  const events = [];
95
169
 
96
170
  /**
97
- * Helper to test if a CallExpression matches a custom function name.
98
- * We simply rely on node.expression.getText() which preserves the fully qualified name.
171
+ * Tests if a CallExpression matches a custom function name
172
+ * @param {Object} callNode - The call expression node
173
+ * @param {string} functionName - Function name to match
174
+ * @returns {boolean} True if matches
99
175
  */
100
- const matchesCustomFn = (callNode, fnName) => {
101
- if (!fnName) return false;
176
+ function matchesCustomFunction(callNode, functionName) {
177
+ if (!functionName || !callNode.expression) {
178
+ return false;
179
+ }
180
+
102
181
  try {
103
- return callNode.expression && callNode.expression.getText() === fnName;
182
+ return callNode.expression.getText() === functionName;
104
183
  } catch {
105
184
  return false;
106
185
  }
107
- };
186
+ }
108
187
 
188
+ /**
189
+ * Recursively visits AST nodes to find tracking calls
190
+ * @param {Object} node - Current AST node
191
+ */
109
192
  function visit(node) {
110
193
  try {
111
194
  if (ts.isCallExpression(node)) {
112
- let matchedCustom = null;
195
+ let matchedCustomConfig = null;
113
196
 
197
+ // Check for custom function matches
114
198
  if (Array.isArray(customConfigs) && customConfigs.length > 0) {
115
- for (const cfg of customConfigs) {
116
- if (cfg && matchesCustomFn(node, cfg.functionName)) {
117
- matchedCustom = cfg;
199
+ for (const config of customConfigs) {
200
+ if (config && matchesCustomFunction(node, config.functionName)) {
201
+ matchedCustomConfig = config;
118
202
  break;
119
203
  }
120
204
  }
@@ -125,9 +209,12 @@ function findTrackingEvents(sourceFile, checker, filePath, customConfigs = []) {
125
209
  sourceFile,
126
210
  checker,
127
211
  filePath,
128
- matchedCustom /* may be null */
212
+ matchedCustomConfig
129
213
  );
130
- if (event) events.push(event);
214
+
215
+ if (event) {
216
+ events.push(event);
217
+ }
131
218
  }
132
219
 
133
220
  ts.forEachChild(node, visit);
@@ -137,7 +224,6 @@ function findTrackingEvents(sourceFile, checker, filePath, customConfigs = []) {
137
224
  }
138
225
 
139
226
  ts.forEachChild(sourceFile, visit);
140
-
141
227
  return events;
142
228
  }
143
229
 
@@ -172,5 +258,6 @@ module.exports = {
172
258
  getProgram,
173
259
  findTrackingEvents,
174
260
  ProgramError,
175
- SourceFileError
261
+ SourceFileError,
262
+ DEFAULT_COMPILER_OPTIONS
176
263
  };
@@ -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
  /**