@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 +1 -1
- package/src/analyze/index.js +87 -24
- package/src/analyze/javascript/extractors/event-extractor.js +9 -2
- package/src/analyze/javascript/utils/function-finder.js +70 -5
- package/src/analyze/python/index.js +45 -26
- package/src/analyze/ruby/extractors.js +15 -1
- package/src/analyze/ruby/index.js +99 -3
- package/src/analyze/ruby/visitor.js +5 -10
- package/src/analyze/typescript/extractors/event-extractor.js +14 -15
- package/src/analyze/typescript/utils/function-finder.js +47 -5
package/package.json
CHANGED
package/src/analyze/index.js
CHANGED
|
@@ -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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
164
|
+
// Must at least have an event name – properties are optional.
|
|
165
|
+
if (!eventName) {
|
|
165
166
|
return null;
|
|
166
167
|
}
|
|
167
168
|
|
|
168
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
if (
|
|
21
|
-
|
|
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
|
-
//
|
|
73
|
-
const
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
114
|
+
// Prepare config argument (array or null)
|
|
115
|
+
const configArg = Array.isArray(customFunctionSignatures) && customFunctionSignatures.length > 0
|
|
116
|
+
? customFunctionSignatures
|
|
117
|
+
: null;
|
|
100
118
|
|
|
101
|
-
|
|
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
|
-
//
|
|
195
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
52
|
-
|
|
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
|
-
|
|
189
|
+
// Require an event name – properties are optional.
|
|
190
|
+
if (!eventName) {
|
|
190
191
|
return null;
|
|
191
192
|
}
|
|
192
193
|
|
|
193
|
-
let properties =
|
|
194
|
+
let properties = {};
|
|
194
195
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
19
|
-
|
|
20
|
-
if (
|
|
21
|
-
|
|
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
|
-
|
|
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
|
/**
|