@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 +1 -1
- package/src/analyze/index.js +146 -50
- 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/index.js +135 -17
- package/src/analyze/typescript/parser.js +129 -42
- package/src/analyze/typescript/utils/function-finder.js +47 -5
package/package.json
CHANGED
package/src/analyze/index.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
151
|
+
nonTsFiles.push(file);
|
|
152
|
+
if (/\.rb$/.test(file)) {
|
|
153
|
+
rubyFiles.push(file);
|
|
154
|
+
}
|
|
45
155
|
}
|
|
156
|
+
}
|
|
46
157
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = {
|
|
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
|
-
//
|
|
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
|
-
|
|
53
|
-
|
|
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
|
-
|
|
79
|
-
return
|
|
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
|
-
*
|
|
98
|
-
*
|
|
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
|
-
|
|
101
|
-
if (!
|
|
176
|
+
function matchesCustomFunction(callNode, functionName) {
|
|
177
|
+
if (!functionName || !callNode.expression) {
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
|
|
102
181
|
try {
|
|
103
|
-
return callNode.expression
|
|
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
|
|
195
|
+
let matchedCustomConfig = null;
|
|
113
196
|
|
|
197
|
+
// Check for custom function matches
|
|
114
198
|
if (Array.isArray(customConfigs) && customConfigs.length > 0) {
|
|
115
|
-
for (const
|
|
116
|
-
if (
|
|
117
|
-
|
|
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
|
-
|
|
212
|
+
matchedCustomConfig
|
|
129
213
|
);
|
|
130
|
-
|
|
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
|
|
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
|
/**
|