@flisk/analyze-tracking 0.8.0 → 0.8.2
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 +3 -7
- package/src/analyze/javascript/extractors/event-extractor.js +44 -14
- package/src/analyze/javascript/parser.js +59 -13
- package/src/analyze/ruby/extractors.js +33 -6
- package/src/analyze/ruby/index.js +157 -2
- package/src/analyze/ruby/traversal.js +54 -2
- package/src/analyze/ruby/visitor.js +3 -2
- package/src/analyze/typescript/extractors/event-extractor.js +66 -18
- package/src/analyze/typescript/parser.js +26 -4
package/package.json
CHANGED
package/src/analyze/index.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const path = require('path');
|
|
7
|
-
|
|
7
|
+
|
|
8
8
|
const { parseCustomFunctionSignature } = require('./utils/customFunctionParser');
|
|
9
9
|
const { getAllFiles } = require('../utils/fileProcessor');
|
|
10
10
|
const { analyzeJsFile } = require('./javascript');
|
|
@@ -19,11 +19,6 @@ async function analyzeDirectory(dirPath, customFunctions) {
|
|
|
19
19
|
const customFunctionSignatures = (customFunctions && customFunctions?.length > 0) ? customFunctions.map(parseCustomFunctionSignature) : null;
|
|
20
20
|
|
|
21
21
|
const files = getAllFiles(dirPath);
|
|
22
|
-
const tsFiles = files.filter(file => /\.(tsx?)$/.test(file));
|
|
23
|
-
const tsProgram = ts.createProgram(tsFiles, {
|
|
24
|
-
target: ts.ScriptTarget.ESNext,
|
|
25
|
-
module: ts.ModuleKind.CommonJS,
|
|
26
|
-
});
|
|
27
22
|
|
|
28
23
|
for (const file of files) {
|
|
29
24
|
let events = [];
|
|
@@ -37,7 +32,8 @@ async function analyzeDirectory(dirPath, customFunctions) {
|
|
|
37
32
|
if (isJsFile) {
|
|
38
33
|
events = analyzeJsFile(file, customFunctionSignatures);
|
|
39
34
|
} else if (isTsFile) {
|
|
40
|
-
|
|
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);
|
|
41
37
|
} else if (isPythonFile) {
|
|
42
38
|
events = await analyzePythonFile(file, customFunctionSignatures);
|
|
43
39
|
} else if (isRubyFile) {
|
|
@@ -28,29 +28,31 @@ const EXTRACTION_STRATEGIES = {
|
|
|
28
28
|
* Extracts event information from a CallExpression node
|
|
29
29
|
* @param {Object} node - AST CallExpression node
|
|
30
30
|
* @param {string} source - Analytics provider source
|
|
31
|
+
* @param {Object} constantMap - Collected constant map
|
|
31
32
|
* @param {Object} customConfig - Parsed custom function configuration
|
|
32
33
|
* @returns {EventData} Extracted event data
|
|
33
34
|
*/
|
|
34
|
-
function extractEventData(node, source, customConfig) {
|
|
35
|
+
function extractEventData(node, source, constantMap = {}, customConfig) {
|
|
35
36
|
const strategy = EXTRACTION_STRATEGIES[source] || EXTRACTION_STRATEGIES.default;
|
|
36
37
|
if (source === 'custom') {
|
|
37
|
-
return strategy(node, customConfig);
|
|
38
|
+
return strategy(node, constantMap, customConfig);
|
|
38
39
|
}
|
|
39
|
-
return strategy(node);
|
|
40
|
+
return strategy(node, constantMap);
|
|
40
41
|
}
|
|
41
42
|
|
|
42
43
|
/**
|
|
43
44
|
* Extracts Google Analytics event data
|
|
44
45
|
* @param {Object} node - CallExpression node
|
|
46
|
+
* @param {Object} constantMap - Collected constant map
|
|
45
47
|
* @returns {EventData}
|
|
46
48
|
*/
|
|
47
|
-
function extractGoogleAnalyticsEvent(node) {
|
|
49
|
+
function extractGoogleAnalyticsEvent(node, constantMap) {
|
|
48
50
|
if (!node.arguments || node.arguments.length < 3) {
|
|
49
51
|
return { eventName: null, propertiesNode: null };
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
// gtag('event', 'event_name', { properties })
|
|
53
|
-
const eventName = getStringValue(node.arguments[1]);
|
|
55
|
+
const eventName = getStringValue(node.arguments[1], constantMap);
|
|
54
56
|
const propertiesNode = node.arguments[2];
|
|
55
57
|
|
|
56
58
|
return { eventName, propertiesNode };
|
|
@@ -59,9 +61,10 @@ function extractGoogleAnalyticsEvent(node) {
|
|
|
59
61
|
/**
|
|
60
62
|
* Extracts Snowplow event data
|
|
61
63
|
* @param {Object} node - CallExpression node
|
|
64
|
+
* @param {Object} constantMap - Collected constant map
|
|
62
65
|
* @returns {EventData}
|
|
63
66
|
*/
|
|
64
|
-
function extractSnowplowEvent(node) {
|
|
67
|
+
function extractSnowplowEvent(node, constantMap) {
|
|
65
68
|
if (!node.arguments || node.arguments.length === 0) {
|
|
66
69
|
return { eventName: null, propertiesNode: null };
|
|
67
70
|
}
|
|
@@ -75,7 +78,7 @@ function extractSnowplowEvent(node) {
|
|
|
75
78
|
|
|
76
79
|
if (structEventArg.type === NODE_TYPES.OBJECT_EXPRESSION) {
|
|
77
80
|
const actionProperty = findPropertyByKey(structEventArg, 'action');
|
|
78
|
-
const eventName = actionProperty ? getStringValue(actionProperty.value) : null;
|
|
81
|
+
const eventName = actionProperty ? getStringValue(actionProperty.value, constantMap) : null;
|
|
79
82
|
|
|
80
83
|
return { eventName, propertiesNode: structEventArg };
|
|
81
84
|
}
|
|
@@ -87,15 +90,16 @@ function extractSnowplowEvent(node) {
|
|
|
87
90
|
/**
|
|
88
91
|
* Extracts mParticle event data
|
|
89
92
|
* @param {Object} node - CallExpression node
|
|
93
|
+
* @param {Object} constantMap - Collected constant map
|
|
90
94
|
* @returns {EventData}
|
|
91
95
|
*/
|
|
92
|
-
function extractMparticleEvent(node) {
|
|
96
|
+
function extractMparticleEvent(node, constantMap) {
|
|
93
97
|
if (!node.arguments || node.arguments.length < 3) {
|
|
94
98
|
return { eventName: null, propertiesNode: null };
|
|
95
99
|
}
|
|
96
100
|
|
|
97
101
|
// mParticle.logEvent('event_name', mParticle.EventType.Navigation, { properties })
|
|
98
|
-
const eventName = getStringValue(node.arguments[0]);
|
|
102
|
+
const eventName = getStringValue(node.arguments[0], constantMap);
|
|
99
103
|
const propertiesNode = node.arguments[2];
|
|
100
104
|
|
|
101
105
|
return { eventName, propertiesNode };
|
|
@@ -104,15 +108,16 @@ function extractMparticleEvent(node) {
|
|
|
104
108
|
/**
|
|
105
109
|
* Default event extraction for standard providers
|
|
106
110
|
* @param {Object} node - CallExpression node
|
|
111
|
+
* @param {Object} constantMap - Collected constant map
|
|
107
112
|
* @returns {EventData}
|
|
108
113
|
*/
|
|
109
|
-
function extractDefaultEvent(node) {
|
|
114
|
+
function extractDefaultEvent(node, constantMap) {
|
|
110
115
|
if (!node.arguments || node.arguments.length < 2) {
|
|
111
116
|
return { eventName: null, propertiesNode: null };
|
|
112
117
|
}
|
|
113
118
|
|
|
114
119
|
// provider.track('event_name', { properties })
|
|
115
|
-
const eventName = getStringValue(node.arguments[0]);
|
|
120
|
+
const eventName = getStringValue(node.arguments[0], constantMap);
|
|
116
121
|
const propertiesNode = node.arguments[1];
|
|
117
122
|
|
|
118
123
|
return { eventName, propertiesNode };
|
|
@@ -121,16 +126,17 @@ function extractDefaultEvent(node) {
|
|
|
121
126
|
/**
|
|
122
127
|
* Extracts Custom function event data according to signature
|
|
123
128
|
* @param {Object} node - CallExpression node
|
|
129
|
+
* @param {Object} constantMap - Collected constant map
|
|
124
130
|
* @param {Object} customConfig - Parsed custom function configuration
|
|
125
131
|
* @returns {EventData & {extraArgs:Object}} event data plus extra args map
|
|
126
132
|
*/
|
|
127
|
-
function extractCustomEvent(node, customConfig) {
|
|
133
|
+
function extractCustomEvent(node, constantMap, customConfig) {
|
|
128
134
|
const args = node.arguments || [];
|
|
129
135
|
|
|
130
136
|
const eventArg = args[customConfig?.eventIndex ?? 0];
|
|
131
137
|
const propertiesArg = args[customConfig?.propertiesIndex ?? 1];
|
|
132
138
|
|
|
133
|
-
const eventName = getStringValue(eventArg);
|
|
139
|
+
const eventName = getStringValue(eventArg, constantMap);
|
|
134
140
|
|
|
135
141
|
const extraArgs = {};
|
|
136
142
|
if (customConfig && customConfig.extraParams) {
|
|
@@ -197,13 +203,17 @@ function processEventData(eventData, source, filePath, line, functionName, custo
|
|
|
197
203
|
/**
|
|
198
204
|
* Gets string value from an AST node
|
|
199
205
|
* @param {Object} node - AST node
|
|
206
|
+
* @param {Object} constantMap - Collected constant map
|
|
200
207
|
* @returns {string|null} String value or null
|
|
201
208
|
*/
|
|
202
|
-
function getStringValue(node) {
|
|
209
|
+
function getStringValue(node, constantMap = {}) {
|
|
203
210
|
if (!node) return null;
|
|
204
211
|
if (node.type === NODE_TYPES.LITERAL && typeof node.value === 'string') {
|
|
205
212
|
return node.value;
|
|
206
213
|
}
|
|
214
|
+
if (node.type === NODE_TYPES.MEMBER_EXPRESSION) {
|
|
215
|
+
return resolveMemberExpressionToString(node, constantMap);
|
|
216
|
+
}
|
|
207
217
|
return null;
|
|
208
218
|
}
|
|
209
219
|
|
|
@@ -240,6 +250,26 @@ function inferNodeValueType(node) {
|
|
|
240
250
|
}
|
|
241
251
|
}
|
|
242
252
|
|
|
253
|
+
// Helper to resolve MemberExpression (CONST.KEY) to string using collected constant map
|
|
254
|
+
function resolveMemberExpressionToString(node, constantMap) {
|
|
255
|
+
if (!node || node.type !== NODE_TYPES.MEMBER_EXPRESSION) return null;
|
|
256
|
+
if (node.computed) return null; // Only support dot notation
|
|
257
|
+
|
|
258
|
+
const object = node.object;
|
|
259
|
+
const property = node.property;
|
|
260
|
+
|
|
261
|
+
if (object.type !== NODE_TYPES.IDENTIFIER) return null;
|
|
262
|
+
if (property.type !== NODE_TYPES.IDENTIFIER) return null;
|
|
263
|
+
|
|
264
|
+
const objName = object.name;
|
|
265
|
+
const propName = property.name;
|
|
266
|
+
|
|
267
|
+
if (constantMap && constantMap[objName] && typeof constantMap[objName][propName] === 'string') {
|
|
268
|
+
return constantMap[objName][propName];
|
|
269
|
+
}
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
243
273
|
module.exports = {
|
|
244
274
|
extractEventData,
|
|
245
275
|
processEventData
|
|
@@ -120,6 +120,57 @@ function nodeMatchesCustomFunction(node, fnName) {
|
|
|
120
120
|
return false;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
// -----------------------------------------------------------------------------
|
|
124
|
+
// Utility – collect constants defined as plain objects or Object.freeze({...})
|
|
125
|
+
// -----------------------------------------------------------------------------
|
|
126
|
+
function collectConstantStringMap(ast) {
|
|
127
|
+
const map = {};
|
|
128
|
+
|
|
129
|
+
walk.simple(ast, {
|
|
130
|
+
VariableDeclaration(node) {
|
|
131
|
+
// Only consider const declarations
|
|
132
|
+
if (node.kind !== 'const') return;
|
|
133
|
+
node.declarations.forEach(decl => {
|
|
134
|
+
if (decl.id.type !== NODE_TYPES.IDENTIFIER || !decl.init) return;
|
|
135
|
+
const name = decl.id.name;
|
|
136
|
+
let objLiteral = null;
|
|
137
|
+
|
|
138
|
+
if (decl.init.type === NODE_TYPES.OBJECT_EXPRESSION) {
|
|
139
|
+
objLiteral = decl.init;
|
|
140
|
+
} else if (decl.init.type === NODE_TYPES.CALL_EXPRESSION) {
|
|
141
|
+
// Check for Object.freeze({...})
|
|
142
|
+
const callee = decl.init.callee;
|
|
143
|
+
if (
|
|
144
|
+
callee &&
|
|
145
|
+
callee.type === NODE_TYPES.MEMBER_EXPRESSION &&
|
|
146
|
+
callee.object.type === NODE_TYPES.IDENTIFIER &&
|
|
147
|
+
callee.object.name === 'Object' &&
|
|
148
|
+
callee.property.type === NODE_TYPES.IDENTIFIER &&
|
|
149
|
+
callee.property.name === 'freeze' &&
|
|
150
|
+
decl.init.arguments.length > 0 &&
|
|
151
|
+
decl.init.arguments[0].type === NODE_TYPES.OBJECT_EXPRESSION
|
|
152
|
+
) {
|
|
153
|
+
objLiteral = decl.init.arguments[0];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (objLiteral) {
|
|
158
|
+
map[name] = {};
|
|
159
|
+
objLiteral.properties.forEach(prop => {
|
|
160
|
+
if (!prop.key || !prop.value) return;
|
|
161
|
+
const keyName = prop.key.name || prop.key.value;
|
|
162
|
+
if (prop.value.type === NODE_TYPES.LITERAL && typeof prop.value.value === 'string') {
|
|
163
|
+
map[name][keyName] = prop.value.value;
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return map;
|
|
172
|
+
}
|
|
173
|
+
|
|
123
174
|
/**
|
|
124
175
|
* Walk the AST once and find tracking events for built-in providers plus any number of custom
|
|
125
176
|
* function configurations. This avoids the previous O(n * customConfigs) behaviour.
|
|
@@ -132,6 +183,9 @@ function nodeMatchesCustomFunction(node, fnName) {
|
|
|
132
183
|
function findTrackingEvents(ast, filePath, customConfigs = []) {
|
|
133
184
|
const events = [];
|
|
134
185
|
|
|
186
|
+
// Collect constant mappings once per file
|
|
187
|
+
const constantMap = collectConstantStringMap(ast);
|
|
188
|
+
|
|
135
189
|
walk.ancestor(ast, {
|
|
136
190
|
[NODE_TYPES.CALL_EXPRESSION]: (node, ancestors) => {
|
|
137
191
|
try {
|
|
@@ -148,12 +202,10 @@ function findTrackingEvents(ast, filePath, customConfigs = []) {
|
|
|
148
202
|
}
|
|
149
203
|
|
|
150
204
|
if (matchedCustomConfig) {
|
|
151
|
-
|
|
152
|
-
const event = extractTrackingEvent(node, ancestors, filePath, matchedCustomConfig);
|
|
205
|
+
const event = extractTrackingEvent(node, ancestors, filePath, constantMap, matchedCustomConfig);
|
|
153
206
|
if (event) events.push(event);
|
|
154
207
|
} else {
|
|
155
|
-
|
|
156
|
-
const event = extractTrackingEvent(node, ancestors, filePath, null);
|
|
208
|
+
const event = extractTrackingEvent(node, ancestors, filePath, constantMap, null);
|
|
157
209
|
if (event) events.push(event);
|
|
158
210
|
}
|
|
159
211
|
} catch (error) {
|
|
@@ -170,24 +222,18 @@ function findTrackingEvents(ast, filePath, customConfigs = []) {
|
|
|
170
222
|
* @param {Object} node - CallExpression node
|
|
171
223
|
* @param {Array<Object>} ancestors - Ancestor nodes
|
|
172
224
|
* @param {string} filePath - File path
|
|
225
|
+
* @param {Object} constantMap - Constant string map
|
|
173
226
|
* @param {Object} [customConfig] - Custom function configuration object
|
|
174
227
|
* @returns {Object|null} Extracted event or null
|
|
175
228
|
*/
|
|
176
|
-
function extractTrackingEvent(node, ancestors, filePath, customConfig) {
|
|
177
|
-
// Detect the analytics source
|
|
229
|
+
function extractTrackingEvent(node, ancestors, filePath, constantMap, customConfig) {
|
|
178
230
|
const source = detectAnalyticsSource(node, customConfig?.functionName);
|
|
179
231
|
if (source === 'unknown') {
|
|
180
232
|
return null;
|
|
181
233
|
}
|
|
182
|
-
|
|
183
|
-
// Extract event data based on the source
|
|
184
|
-
const eventData = extractEventData(node, source, customConfig);
|
|
185
|
-
|
|
186
|
-
// Get location and context information
|
|
234
|
+
const eventData = extractEventData(node, source, constantMap, customConfig);
|
|
187
235
|
const line = node.loc.start.line;
|
|
188
236
|
const functionName = findWrappingFunction(node, ancestors);
|
|
189
|
-
|
|
190
|
-
// Process the event data into final format
|
|
191
237
|
return processEventData(eventData, source, filePath, line, functionName, customConfig);
|
|
192
238
|
}
|
|
193
239
|
|
|
@@ -4,15 +4,17 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const { getValueType } = require('./types');
|
|
7
|
+
const prismPromise = import('@ruby/prism');
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Extracts the event name from a tracking call based on the source
|
|
10
11
|
* @param {Object} node - The AST CallNode
|
|
11
12
|
* @param {string} source - The detected analytics source
|
|
12
13
|
* @param {Object} customConfig - Custom configuration for custom functions
|
|
14
|
+
* @param {Object} constantMap - Map of constants to resolve constant paths
|
|
13
15
|
* @returns {string|null} - The extracted event name or null
|
|
14
16
|
*/
|
|
15
|
-
function extractEventName(node, source, customConfig = null) {
|
|
17
|
+
async function extractEventName(node, source, customConfig = null, constantMap = {}) {
|
|
16
18
|
if (source === 'segment' || source === 'rudderstack') {
|
|
17
19
|
// Both Segment and Rudderstack use the same format
|
|
18
20
|
const params = node.arguments_?.arguments_?.[0]?.elements;
|
|
@@ -62,8 +64,33 @@ function extractEventName(node, source, customConfig = null) {
|
|
|
62
64
|
}
|
|
63
65
|
|
|
64
66
|
const eventArg = args[customConfig.eventIndex];
|
|
65
|
-
if (eventArg
|
|
66
|
-
|
|
67
|
+
if (eventArg) {
|
|
68
|
+
// String literal
|
|
69
|
+
if (eventArg.unescaped?.value) {
|
|
70
|
+
return eventArg.unescaped.value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Constant references
|
|
74
|
+
const { ConstantReadNode, ConstantPathNode } = await prismPromise;
|
|
75
|
+
const buildConstPath = (n) => {
|
|
76
|
+
if (!n) return '';
|
|
77
|
+
if (n instanceof ConstantReadNode) return n.name;
|
|
78
|
+
if (n instanceof ConstantPathNode) {
|
|
79
|
+
const parent = buildConstPath(n.parent);
|
|
80
|
+
return parent ? `${parent}::${n.name}` : n.name;
|
|
81
|
+
}
|
|
82
|
+
return '';
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
if (eventArg instanceof ConstantReadNode) {
|
|
86
|
+
const name = eventArg.name;
|
|
87
|
+
// Try to resolve with current constant map, else return name
|
|
88
|
+
return constantMap[name] || name;
|
|
89
|
+
}
|
|
90
|
+
if (eventArg instanceof ConstantPathNode) {
|
|
91
|
+
const pathStr = buildConstPath(eventArg);
|
|
92
|
+
return constantMap[pathStr] || pathStr;
|
|
93
|
+
}
|
|
67
94
|
}
|
|
68
95
|
return null;
|
|
69
96
|
}
|
|
@@ -79,7 +106,7 @@ function extractEventName(node, source, customConfig = null) {
|
|
|
79
106
|
* @returns {Object|null} - The extracted properties or null
|
|
80
107
|
*/
|
|
81
108
|
async function extractProperties(node, source, customConfig = null) {
|
|
82
|
-
const { HashNode, ArrayNode } = await
|
|
109
|
+
const { HashNode, ArrayNode } = await prismPromise;
|
|
83
110
|
|
|
84
111
|
if (source === 'segment' || source === 'rudderstack') {
|
|
85
112
|
// Both Segment and Rudderstack use the same format
|
|
@@ -235,7 +262,7 @@ async function extractProperties(node, source, customConfig = null) {
|
|
|
235
262
|
* @returns {Object} - The extracted properties
|
|
236
263
|
*/
|
|
237
264
|
async function extractHashProperties(hashNode) {
|
|
238
|
-
const { AssocNode, HashNode, ArrayNode } = await
|
|
265
|
+
const { AssocNode, HashNode, ArrayNode } = await prismPromise;
|
|
239
266
|
const properties = {};
|
|
240
267
|
|
|
241
268
|
for (const element of hashNode.elements) {
|
|
@@ -278,7 +305,7 @@ async function extractHashProperties(hashNode) {
|
|
|
278
305
|
* @returns {Object} - Type information for array items
|
|
279
306
|
*/
|
|
280
307
|
async function extractArrayItemProperties(arrayNode) {
|
|
281
|
-
const { HashNode } = await
|
|
308
|
+
const { HashNode } = await prismPromise;
|
|
282
309
|
|
|
283
310
|
if (arrayNode.elements.length === 0) {
|
|
284
311
|
return { type: 'any' };
|
|
@@ -4,11 +4,162 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
7
8
|
const TrackingVisitor = require('./visitor');
|
|
8
9
|
|
|
9
10
|
// Lazy-loaded parse function from Ruby Prism
|
|
10
11
|
let parse = null;
|
|
11
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Extracts the string literal value from an AST node, ignoring a trailing `.freeze` call.
|
|
15
|
+
* Supports:
|
|
16
|
+
* - StringNode
|
|
17
|
+
* - CallNode with receiver StringNode and method `freeze`
|
|
18
|
+
*
|
|
19
|
+
* @param {import('@ruby/prism').PrismNode} node
|
|
20
|
+
* @returns {string|null}
|
|
21
|
+
*/
|
|
22
|
+
async function extractStringLiteral(node) {
|
|
23
|
+
if (!node) return null;
|
|
24
|
+
|
|
25
|
+
const {
|
|
26
|
+
StringNode,
|
|
27
|
+
CallNode
|
|
28
|
+
} = await import('@ruby/prism');
|
|
29
|
+
|
|
30
|
+
if (node instanceof StringNode) {
|
|
31
|
+
return node.unescaped?.value ?? null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Handle "_Section".freeze pattern
|
|
35
|
+
if (node instanceof CallNode && node.name === 'freeze' && node.receiver) {
|
|
36
|
+
return extractStringLiteral(node.receiver);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Recursively traverses an AST to collect constant assignments and build a map
|
|
44
|
+
* of fully-qualified constant names (e.g. "TelemetryHelper::FINISHED_SECTION")
|
|
45
|
+
* to their string literal values.
|
|
46
|
+
*
|
|
47
|
+
* @param {import('@ruby/prism').PrismNode} node - current AST node
|
|
48
|
+
* @param {string[]} namespaceStack - stack of module/class names
|
|
49
|
+
* @param {Object} constantMap - accumulator map of constant path -> string value
|
|
50
|
+
*/
|
|
51
|
+
async function collectConstants(node, namespaceStack, constantMap) {
|
|
52
|
+
if (!node) return;
|
|
53
|
+
|
|
54
|
+
const {
|
|
55
|
+
ModuleNode,
|
|
56
|
+
ClassNode,
|
|
57
|
+
StatementsNode,
|
|
58
|
+
ConstantWriteNode,
|
|
59
|
+
ConstantPathWriteNode,
|
|
60
|
+
ConstantPathNode
|
|
61
|
+
} = await import('@ruby/prism');
|
|
62
|
+
|
|
63
|
+
// Helper to build constant path from ConstantPathNode
|
|
64
|
+
const buildConstPath = (pathNode) => {
|
|
65
|
+
if (!pathNode) return '';
|
|
66
|
+
if (pathNode.type === 'ConstantReadNode') {
|
|
67
|
+
return pathNode.name;
|
|
68
|
+
}
|
|
69
|
+
if (pathNode.type === 'ConstantPathNode') {
|
|
70
|
+
const parent = buildConstPath(pathNode.parent);
|
|
71
|
+
return parent ? `${parent}::${pathNode.name}` : pathNode.name;
|
|
72
|
+
}
|
|
73
|
+
return '';
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Process constant assignments
|
|
77
|
+
if (node instanceof ConstantWriteNode) {
|
|
78
|
+
const fullName = [...namespaceStack, node.name].join('::');
|
|
79
|
+
const literal = await extractStringLiteral(node.value);
|
|
80
|
+
if (literal !== null) {
|
|
81
|
+
constantMap[fullName] = literal;
|
|
82
|
+
}
|
|
83
|
+
} else if (node instanceof ConstantPathWriteNode) {
|
|
84
|
+
const fullName = buildConstPath(node.target);
|
|
85
|
+
const literal = await extractStringLiteral(node.value);
|
|
86
|
+
if (fullName && literal !== null) {
|
|
87
|
+
constantMap[fullName] = literal;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Recurse into children depending on node type
|
|
92
|
+
if (node instanceof ModuleNode || node instanceof ClassNode) {
|
|
93
|
+
// Enter namespace
|
|
94
|
+
const name = node.constantPath?.name || node.name; // ModuleNode has constantPath
|
|
95
|
+
const childNamespaceStack = name ? [...namespaceStack, name] : namespaceStack;
|
|
96
|
+
|
|
97
|
+
if (node.body) {
|
|
98
|
+
await collectConstants(node.body, childNamespaceStack, constantMap);
|
|
99
|
+
}
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Generic traversal for other nodes
|
|
104
|
+
if (node instanceof StatementsNode) {
|
|
105
|
+
for (const child of node.body) {
|
|
106
|
+
await collectConstants(child, namespaceStack, constantMap);
|
|
107
|
+
}
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fallback: iterate over enumerable properties to find nested nodes
|
|
112
|
+
for (const key of Object.keys(node)) {
|
|
113
|
+
const val = node[key];
|
|
114
|
+
if (!val) continue;
|
|
115
|
+
|
|
116
|
+
const traverseChild = async (child) => {
|
|
117
|
+
if (child && typeof child === 'object' && (child.location || child.constructor?.name?.endsWith('Node'))) {
|
|
118
|
+
await collectConstants(child, namespaceStack, constantMap);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (Array.isArray(val)) {
|
|
123
|
+
for (const c of val) {
|
|
124
|
+
await traverseChild(c);
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
await traverseChild(val);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Builds a map of constant names to their literal string values for all .rb
|
|
134
|
+
* files in the given directory. This is a best-effort resolver intended for
|
|
135
|
+
* test fixtures and small projects and is not a fully-fledged Ruby constant
|
|
136
|
+
* resolver.
|
|
137
|
+
*
|
|
138
|
+
* @param {string} directory
|
|
139
|
+
* @returns {Promise<Object<string,string>>}
|
|
140
|
+
*/
|
|
141
|
+
async function buildConstantMapForDirectory(directory) {
|
|
142
|
+
const constantMap = {};
|
|
143
|
+
|
|
144
|
+
if (!fs.existsSync(directory)) return constantMap;
|
|
145
|
+
|
|
146
|
+
const files = fs.readdirSync(directory).filter(f => f.endsWith('.rb'));
|
|
147
|
+
|
|
148
|
+
for (const file of files) {
|
|
149
|
+
const fullPath = path.join(directory, file);
|
|
150
|
+
try {
|
|
151
|
+
const content = fs.readFileSync(fullPath, 'utf8');
|
|
152
|
+
const ast = await parse(content);
|
|
153
|
+
await collectConstants(ast.value, [], constantMap);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
// Ignore parse errors for unrelated files
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return constantMap;
|
|
161
|
+
}
|
|
162
|
+
|
|
12
163
|
/**
|
|
13
164
|
* Analyzes a Ruby file for analytics tracking calls
|
|
14
165
|
* @param {string} filePath - Path to the Ruby file to analyze
|
|
@@ -27,6 +178,10 @@ async function analyzeRubyFile(filePath, customFunctionSignatures = null) {
|
|
|
27
178
|
// Read the file content
|
|
28
179
|
const code = fs.readFileSync(filePath, 'utf8');
|
|
29
180
|
|
|
181
|
+
// Build constant map for current directory (sibling .rb files)
|
|
182
|
+
const currentDir = path.dirname(filePath);
|
|
183
|
+
const constantMap = await buildConstantMapForDirectory(currentDir);
|
|
184
|
+
|
|
30
185
|
// Parse the Ruby code into an AST once
|
|
31
186
|
let ast;
|
|
32
187
|
try {
|
|
@@ -36,8 +191,8 @@ async function analyzeRubyFile(filePath, customFunctionSignatures = null) {
|
|
|
36
191
|
return [];
|
|
37
192
|
}
|
|
38
193
|
|
|
39
|
-
// Single visitor pass covering all custom configs
|
|
40
|
-
const visitor = new TrackingVisitor(code, filePath, customFunctionSignatures || []);
|
|
194
|
+
// Single visitor pass covering all custom configs, with constant map for resolution
|
|
195
|
+
const visitor = new TrackingVisitor(code, filePath, customFunctionSignatures || [], constantMap);
|
|
41
196
|
const events = await visitor.analyze(ast);
|
|
42
197
|
|
|
43
198
|
// Deduplicate events
|
|
@@ -46,7 +46,9 @@ async function traverseNode(node, nodeVisitor, ancestors = []) {
|
|
|
46
46
|
AssocNode,
|
|
47
47
|
ClassNode,
|
|
48
48
|
ModuleNode,
|
|
49
|
-
CallNode
|
|
49
|
+
CallNode,
|
|
50
|
+
CaseNode,
|
|
51
|
+
WhenNode
|
|
50
52
|
} = await import('@ruby/prism');
|
|
51
53
|
|
|
52
54
|
if (!node) return;
|
|
@@ -89,7 +91,8 @@ async function traverseNode(node, nodeVisitor, ancestors = []) {
|
|
|
89
91
|
await traverseNode(node.body, nodeVisitor, ancestors);
|
|
90
92
|
}
|
|
91
93
|
} else if (node instanceof ArgumentsNode) {
|
|
92
|
-
|
|
94
|
+
const argsList = node.arguments || [];
|
|
95
|
+
for (const arg of argsList) {
|
|
93
96
|
await traverseNode(arg, nodeVisitor, ancestors);
|
|
94
97
|
}
|
|
95
98
|
} else if (node instanceof HashNode) {
|
|
@@ -99,6 +102,55 @@ async function traverseNode(node, nodeVisitor, ancestors = []) {
|
|
|
99
102
|
} else if (node instanceof AssocNode) {
|
|
100
103
|
await traverseNode(node.key, nodeVisitor, ancestors);
|
|
101
104
|
await traverseNode(node.value, nodeVisitor, ancestors);
|
|
105
|
+
} else if (node instanceof CaseNode) {
|
|
106
|
+
// Traverse through each 'when' clause and the optional else clause
|
|
107
|
+
const whenClauses = node.whens || node.conditions || node.when_bodies || [];
|
|
108
|
+
for (const when of whenClauses) {
|
|
109
|
+
await traverseNode(when, nodeVisitor, ancestors);
|
|
110
|
+
}
|
|
111
|
+
if (node.else_) {
|
|
112
|
+
await traverseNode(node.else_, nodeVisitor, ancestors);
|
|
113
|
+
} else if (node.elseBody) {
|
|
114
|
+
await traverseNode(node.elseBody, nodeVisitor, ancestors);
|
|
115
|
+
}
|
|
116
|
+
} else if (node instanceof WhenNode) {
|
|
117
|
+
// Handle a single when clause: traverse its condition(s) and body
|
|
118
|
+
if (Array.isArray(node.conditions)) {
|
|
119
|
+
for (const cond of node.conditions) {
|
|
120
|
+
await traverseNode(cond, nodeVisitor, ancestors);
|
|
121
|
+
}
|
|
122
|
+
} else if (node.conditions) {
|
|
123
|
+
await traverseNode(node.conditions, nodeVisitor, ancestors);
|
|
124
|
+
}
|
|
125
|
+
if (node.statements) {
|
|
126
|
+
await traverseNode(node.statements, nodeVisitor, ancestors);
|
|
127
|
+
}
|
|
128
|
+
if (node.next) {
|
|
129
|
+
await traverseNode(node.next, nodeVisitor, ancestors);
|
|
130
|
+
}
|
|
131
|
+
} else {
|
|
132
|
+
// Generic fallback: iterate over enumerable properties to find nested nodes
|
|
133
|
+
for (const key of Object.keys(node)) {
|
|
134
|
+
const val = node[key];
|
|
135
|
+
if (!val) continue;
|
|
136
|
+
|
|
137
|
+
const visitChild = async (child) => {
|
|
138
|
+
if (child && typeof child === 'object') {
|
|
139
|
+
// crude check: Prism nodes have a `location` field
|
|
140
|
+
if (child.location || child.type || child.constructor?.name?.endsWith('Node')) {
|
|
141
|
+
await traverseNode(child, nodeVisitor, ancestors);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
if (Array.isArray(val)) {
|
|
147
|
+
for (const c of val) {
|
|
148
|
+
await visitChild(c);
|
|
149
|
+
}
|
|
150
|
+
} else {
|
|
151
|
+
await visitChild(val);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
102
154
|
}
|
|
103
155
|
|
|
104
156
|
ancestors.pop();
|
|
@@ -8,10 +8,11 @@ const { extractEventName, extractProperties } = require('./extractors');
|
|
|
8
8
|
const { findWrappingFunction, traverseNode, getLineNumber } = require('./traversal');
|
|
9
9
|
|
|
10
10
|
class TrackingVisitor {
|
|
11
|
-
constructor(code, filePath, customConfigs = []) {
|
|
11
|
+
constructor(code, filePath, customConfigs = [], constantMap = {}) {
|
|
12
12
|
this.code = code;
|
|
13
13
|
this.filePath = filePath;
|
|
14
14
|
this.customConfigs = Array.isArray(customConfigs) ? customConfigs : [];
|
|
15
|
+
this.constantMap = constantMap || {};
|
|
15
16
|
this.events = [];
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -42,7 +43,7 @@ class TrackingVisitor {
|
|
|
42
43
|
|
|
43
44
|
if (!source) return;
|
|
44
45
|
|
|
45
|
-
const eventName = extractEventName(node, source, matchedConfig);
|
|
46
|
+
const eventName = await extractEventName(node, source, matchedConfig, this.constantMap);
|
|
46
47
|
if (!eventName) return;
|
|
47
48
|
|
|
48
49
|
const line = getLineNumber(this.code, node.location);
|
|
@@ -296,27 +296,75 @@ function resolvePropertyAccessToString(node, checker, sourceFile) {
|
|
|
296
296
|
try {
|
|
297
297
|
// Get the symbol for the property access
|
|
298
298
|
const symbol = checker.getSymbolAtLocation(node);
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
299
|
+
if (symbol && symbol.valueDeclaration) {
|
|
300
|
+
// Check if it's a property assignment with a string initializer
|
|
301
|
+
if (ts.isPropertyAssignment(symbol.valueDeclaration) &&
|
|
302
|
+
symbol.valueDeclaration.initializer &&
|
|
303
|
+
ts.isStringLiteral(symbol.valueDeclaration.initializer)) {
|
|
304
|
+
return symbol.valueDeclaration.initializer.text;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Check if it's a variable declaration property (string literal type)
|
|
308
|
+
if (ts.isPropertySignature(symbol.valueDeclaration) ||
|
|
309
|
+
ts.isMethodSignature(symbol.valueDeclaration)) {
|
|
310
|
+
const type = checker.getTypeAtLocation(node);
|
|
311
|
+
if (type && type.isStringLiteral && type.isStringLiteral()) {
|
|
312
|
+
return type.value;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
308
315
|
}
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
316
|
+
|
|
317
|
+
// ---------------------------------------------------------------------
|
|
318
|
+
// Fallback – manually resolve patterns like:
|
|
319
|
+
// const CONST = { KEY: 'value' };
|
|
320
|
+
// const CONST = Object.freeze({ KEY: 'value' });
|
|
321
|
+
// And later used as CONST.KEY
|
|
322
|
+
// ---------------------------------------------------------------------
|
|
323
|
+
if (ts.isIdentifier(node.expression)) {
|
|
324
|
+
const objIdentifier = node.expression;
|
|
325
|
+
const initializer = resolveIdentifierToInitializer(checker, objIdentifier, sourceFile);
|
|
326
|
+
if (initializer) {
|
|
327
|
+
let objectLiteral = null;
|
|
328
|
+
|
|
329
|
+
// Handle direct object literal initializers
|
|
330
|
+
if (ts.isObjectLiteralExpression(initializer)) {
|
|
331
|
+
objectLiteral = initializer;
|
|
332
|
+
}
|
|
333
|
+
// Handle Object.freeze({ ... }) pattern
|
|
334
|
+
else if (ts.isCallExpression(initializer)) {
|
|
335
|
+
const callee = initializer.expression;
|
|
336
|
+
if (
|
|
337
|
+
ts.isPropertyAccessExpression(callee) &&
|
|
338
|
+
ts.isIdentifier(callee.expression) &&
|
|
339
|
+
callee.expression.escapedText === 'Object' &&
|
|
340
|
+
callee.name.escapedText === 'freeze' &&
|
|
341
|
+
initializer.arguments.length > 0 &&
|
|
342
|
+
ts.isObjectLiteralExpression(initializer.arguments[0])
|
|
343
|
+
) {
|
|
344
|
+
objectLiteral = initializer.arguments[0];
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (objectLiteral) {
|
|
349
|
+
const propNode = findPropertyByKey(objectLiteral, node.name.escapedText || node.name.text);
|
|
350
|
+
if (propNode && propNode.initializer && ts.isStringLiteral(propNode.initializer)) {
|
|
351
|
+
return propNode.initializer.text;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
317
354
|
}
|
|
318
355
|
}
|
|
319
|
-
|
|
356
|
+
|
|
357
|
+
// Final fallback – use type information at location (works for imported Object.freeze constants)
|
|
358
|
+
try {
|
|
359
|
+
const t = checker.getTypeAtLocation(node);
|
|
360
|
+
if (t && t.isStringLiteral && typeof t.isStringLiteral === 'function' && t.isStringLiteral()) {
|
|
361
|
+
return t.value;
|
|
362
|
+
}
|
|
363
|
+
if (t && t.flags && (t.flags & ts.TypeFlags.StringLiteral)) {
|
|
364
|
+
return t.value;
|
|
365
|
+
}
|
|
366
|
+
} catch (_) {/* ignore */}
|
|
367
|
+
|
|
320
368
|
return null;
|
|
321
369
|
} catch (error) {
|
|
322
370
|
return null;
|
|
@@ -7,6 +7,7 @@ const ts = require('typescript');
|
|
|
7
7
|
const { detectAnalyticsSource } = require('./detectors');
|
|
8
8
|
const { extractEventData, processEventData } = require('./extractors');
|
|
9
9
|
const { findWrappingFunction } = require('./utils/function-finder');
|
|
10
|
+
const path = require('path');
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Error thrown when TypeScript program cannot be created
|
|
@@ -44,16 +45,37 @@ function getProgram(filePath, existingProgram) {
|
|
|
44
45
|
}
|
|
45
46
|
|
|
46
47
|
try {
|
|
47
|
-
//
|
|
48
|
-
const
|
|
48
|
+
// Try to locate a tsconfig.json nearest to the file to inherit compiler options (important for path aliases)
|
|
49
|
+
const searchPath = path.dirname(filePath);
|
|
50
|
+
const configPath = ts.findConfigFile(searchPath, ts.sys.fileExists, 'tsconfig.json');
|
|
51
|
+
|
|
52
|
+
let compilerOptions = {
|
|
49
53
|
target: ts.ScriptTarget.Latest,
|
|
50
54
|
module: ts.ModuleKind.CommonJS,
|
|
51
55
|
allowJs: true,
|
|
52
56
|
checkJs: false,
|
|
53
|
-
noEmit: true
|
|
57
|
+
noEmit: true,
|
|
58
|
+
jsx: ts.JsxEmit.Preserve
|
|
54
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
|
+
}
|
|
55
77
|
|
|
56
|
-
const program = ts.createProgram(
|
|
78
|
+
const program = ts.createProgram(rootNames, compilerOptions);
|
|
57
79
|
return program;
|
|
58
80
|
} catch (error) {
|
|
59
81
|
throw new ProgramError(filePath, error);
|