@flisk/analyze-tracking 0.7.5 → 0.8.0
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/README.md +29 -21
- package/bin/cli.js +1 -0
- package/package.json +1 -1
- package/src/analyze/go/astTraversal.js +22 -22
- package/src/analyze/go/eventExtractor.js +10 -7
- package/src/analyze/go/index.js +39 -19
- package/src/analyze/go/propertyExtractor.js +25 -5
- package/src/analyze/go/trackingExtractor.js +5 -5
- package/src/analyze/index.js +9 -6
- package/src/analyze/javascript/detectors/analytics-source.js +55 -2
- package/src/analyze/javascript/extractors/event-extractor.js +69 -2
- package/src/analyze/javascript/index.js +14 -8
- package/src/analyze/javascript/parser.js +87 -14
- package/src/analyze/python/index.js +32 -26
- package/src/analyze/python/pythonTrackingAnalyzer.py +113 -39
- package/src/analyze/ruby/extractors.js +46 -10
- package/src/analyze/ruby/index.js +14 -7
- package/src/analyze/ruby/visitor.js +24 -7
- package/src/analyze/typescript/detectors/analytics-source.js +4 -1
- package/src/analyze/typescript/extractors/event-extractor.js +186 -8
- package/src/analyze/typescript/extractors/property-extractor.js +53 -1
- package/src/analyze/typescript/index.js +16 -10
- package/src/analyze/typescript/parser.js +37 -14
- package/src/analyze/typescript/utils/function-finder.js +11 -0
- package/src/analyze/typescript/utils/type-resolver.js +1 -1
- package/src/analyze/utils/customFunctionParser.js +55 -0
- package/src/index.js +2 -2
|
@@ -16,7 +16,7 @@ let parse = null;
|
|
|
16
16
|
* @returns {Promise<Array>} Array of tracking events found in the file
|
|
17
17
|
* @throws {Error} If the file cannot be read or parsed
|
|
18
18
|
*/
|
|
19
|
-
async function analyzeRubyFile(filePath,
|
|
19
|
+
async function analyzeRubyFile(filePath, customFunctionSignatures = null) {
|
|
20
20
|
// Lazy load the Ruby Prism parser
|
|
21
21
|
if (!parse) {
|
|
22
22
|
const { loadPrism } = await import('@ruby/prism');
|
|
@@ -26,21 +26,28 @@ async function analyzeRubyFile(filePath, customFunction) {
|
|
|
26
26
|
try {
|
|
27
27
|
// Read the file content
|
|
28
28
|
const code = fs.readFileSync(filePath, 'utf8');
|
|
29
|
-
|
|
30
|
-
// Parse the Ruby code into an AST
|
|
29
|
+
|
|
30
|
+
// Parse the Ruby code into an AST once
|
|
31
31
|
let ast;
|
|
32
32
|
try {
|
|
33
33
|
ast = await parse(code);
|
|
34
34
|
} catch (parseError) {
|
|
35
35
|
console.error(`Error parsing file ${filePath}:`, parseError.message);
|
|
36
|
-
return [];
|
|
36
|
+
return [];
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
//
|
|
40
|
-
const visitor = new TrackingVisitor(code, filePath,
|
|
39
|
+
// Single visitor pass covering all custom configs
|
|
40
|
+
const visitor = new TrackingVisitor(code, filePath, customFunctionSignatures || []);
|
|
41
41
|
const events = await visitor.analyze(ast);
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
// Deduplicate events
|
|
44
|
+
const unique = new Map();
|
|
45
|
+
for (const evt of events) {
|
|
46
|
+
const key = `${evt.source}|${evt.eventName}|${evt.line}|${evt.functionName}`;
|
|
47
|
+
if (!unique.has(key)) unique.set(key, evt);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return Array.from(unique.values());
|
|
44
51
|
|
|
45
52
|
} catch (fileError) {
|
|
46
53
|
console.error(`Error reading or processing file ${filePath}:`, fileError.message);
|
|
@@ -8,10 +8,10 @@ const { extractEventName, extractProperties } = require('./extractors');
|
|
|
8
8
|
const { findWrappingFunction, traverseNode, getLineNumber } = require('./traversal');
|
|
9
9
|
|
|
10
10
|
class TrackingVisitor {
|
|
11
|
-
constructor(code, filePath,
|
|
11
|
+
constructor(code, filePath, customConfigs = []) {
|
|
12
12
|
this.code = code;
|
|
13
13
|
this.filePath = filePath;
|
|
14
|
-
this.
|
|
14
|
+
this.customConfigs = Array.isArray(customConfigs) ? customConfigs : [];
|
|
15
15
|
this.events = [];
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -22,10 +22,27 @@ class TrackingVisitor {
|
|
|
22
22
|
*/
|
|
23
23
|
async processCallNode(node, ancestors) {
|
|
24
24
|
try {
|
|
25
|
-
|
|
25
|
+
let matchedConfig = null;
|
|
26
|
+
let source = null;
|
|
27
|
+
|
|
28
|
+
// Try to match any custom config first
|
|
29
|
+
for (const cfg of this.customConfigs) {
|
|
30
|
+
if (!cfg) continue;
|
|
31
|
+
if (detectSource(node, cfg.functionName) === 'custom') {
|
|
32
|
+
matchedConfig = cfg;
|
|
33
|
+
source = 'custom';
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// If no custom match, attempt built-in providers
|
|
39
|
+
if (!source) {
|
|
40
|
+
source = detectSource(node, null);
|
|
41
|
+
}
|
|
42
|
+
|
|
26
43
|
if (!source) return;
|
|
27
44
|
|
|
28
|
-
const eventName = extractEventName(node, source);
|
|
45
|
+
const eventName = extractEventName(node, source, matchedConfig);
|
|
29
46
|
if (!eventName) return;
|
|
30
47
|
|
|
31
48
|
const line = getLineNumber(this.code, node.location);
|
|
@@ -33,13 +50,13 @@ class TrackingVisitor {
|
|
|
33
50
|
// For module-scoped custom functions, use the custom function name as the functionName
|
|
34
51
|
// For simple custom functions, use the wrapping function name
|
|
35
52
|
let functionName;
|
|
36
|
-
if (source === 'custom' &&
|
|
37
|
-
functionName =
|
|
53
|
+
if (source === 'custom' && matchedConfig && matchedConfig.functionName.includes('.')) {
|
|
54
|
+
functionName = matchedConfig.functionName;
|
|
38
55
|
} else {
|
|
39
56
|
functionName = await findWrappingFunction(node, ancestors);
|
|
40
57
|
}
|
|
41
58
|
|
|
42
|
-
const properties = await extractProperties(node, source);
|
|
59
|
+
const properties = await extractProperties(node, source, matchedConfig);
|
|
43
60
|
|
|
44
61
|
this.events.push({
|
|
45
62
|
eventName,
|
|
@@ -48,7 +48,10 @@ function isCustomFunction(node, customFunction) {
|
|
|
48
48
|
ts.isPropertyAccessExpression(node.expression) ||
|
|
49
49
|
ts.isCallExpression(node.expression) || // For chained calls like getTracker().track()
|
|
50
50
|
ts.isElementAccessExpression(node.expression) || // For array/object access like trackers['analytics'].track()
|
|
51
|
-
(
|
|
51
|
+
(node.expression?.expression &&
|
|
52
|
+
ts.isPropertyAccessExpression(node.expression.expression) &&
|
|
53
|
+
node.expression.expression.expression &&
|
|
54
|
+
ts.isThisExpression(node.expression.expression.expression)); // For class methods like this.analytics.track()
|
|
52
55
|
|
|
53
56
|
return canBeCustomFunction && node.expression.getText() === customFunction;
|
|
54
57
|
}
|
|
@@ -21,6 +21,7 @@ const EXTRACTION_STRATEGIES = {
|
|
|
21
21
|
googleanalytics: extractGoogleAnalyticsEvent,
|
|
22
22
|
snowplow: extractSnowplowEvent,
|
|
23
23
|
mparticle: extractMparticleEvent,
|
|
24
|
+
custom: extractCustomEvent,
|
|
24
25
|
default: extractDefaultEvent
|
|
25
26
|
};
|
|
26
27
|
|
|
@@ -30,10 +31,14 @@ const EXTRACTION_STRATEGIES = {
|
|
|
30
31
|
* @param {string} source - Analytics provider source
|
|
31
32
|
* @param {Object} checker - TypeScript type checker
|
|
32
33
|
* @param {Object} sourceFile - TypeScript source file
|
|
34
|
+
* @param {Object} customConfig - Custom configuration for custom extraction
|
|
33
35
|
* @returns {EventData} Extracted event data
|
|
34
36
|
*/
|
|
35
|
-
function extractEventData(node, source, checker, sourceFile) {
|
|
37
|
+
function extractEventData(node, source, checker, sourceFile, customConfig) {
|
|
36
38
|
const strategy = EXTRACTION_STRATEGIES[source] || EXTRACTION_STRATEGIES.default;
|
|
39
|
+
if (source === 'custom') {
|
|
40
|
+
return strategy(node, checker, sourceFile, customConfig);
|
|
41
|
+
}
|
|
37
42
|
return strategy(node, checker, sourceFile);
|
|
38
43
|
}
|
|
39
44
|
|
|
@@ -50,7 +55,7 @@ function extractGoogleAnalyticsEvent(node, checker, sourceFile) {
|
|
|
50
55
|
}
|
|
51
56
|
|
|
52
57
|
// gtag('event', 'event_name', { properties })
|
|
53
|
-
const eventName = getStringValue(node.arguments[1]);
|
|
58
|
+
const eventName = getStringValue(node.arguments[1], checker, sourceFile);
|
|
54
59
|
const propertiesNode = node.arguments[2];
|
|
55
60
|
|
|
56
61
|
return { eventName, propertiesNode };
|
|
@@ -79,7 +84,7 @@ function extractSnowplowEvent(node, checker, sourceFile) {
|
|
|
79
84
|
const structEventArg = firstArg.arguments[0];
|
|
80
85
|
if (ts.isObjectLiteralExpression(structEventArg)) {
|
|
81
86
|
const actionProperty = findPropertyByKey(structEventArg, 'action');
|
|
82
|
-
const eventName = actionProperty ? getStringValue(actionProperty.initializer) : null;
|
|
87
|
+
const eventName = actionProperty ? getStringValue(actionProperty.initializer, checker, sourceFile) : null;
|
|
83
88
|
return { eventName, propertiesNode: structEventArg };
|
|
84
89
|
}
|
|
85
90
|
}
|
|
@@ -93,7 +98,7 @@ function extractSnowplowEvent(node, checker, sourceFile) {
|
|
|
93
98
|
const structEventArg = resolvedNode.arguments[0];
|
|
94
99
|
if (ts.isObjectLiteralExpression(structEventArg)) {
|
|
95
100
|
const actionProperty = findPropertyByKey(structEventArg, 'action');
|
|
96
|
-
const eventName = actionProperty ? getStringValue(actionProperty.initializer) : null;
|
|
101
|
+
const eventName = actionProperty ? getStringValue(actionProperty.initializer, checker, sourceFile) : null;
|
|
97
102
|
return { eventName, propertiesNode: structEventArg };
|
|
98
103
|
}
|
|
99
104
|
}
|
|
@@ -115,12 +120,38 @@ function extractMparticleEvent(node, checker, sourceFile) {
|
|
|
115
120
|
}
|
|
116
121
|
|
|
117
122
|
// mParticle.logEvent('event_name', mParticle.EventType.Navigation, { properties })
|
|
118
|
-
const eventName = getStringValue(node.arguments[0]);
|
|
123
|
+
const eventName = getStringValue(node.arguments[0], checker, sourceFile);
|
|
119
124
|
const propertiesNode = node.arguments[2];
|
|
120
125
|
|
|
121
126
|
return { eventName, propertiesNode };
|
|
122
127
|
}
|
|
123
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Custom extraction
|
|
131
|
+
* @param {Object} node - CallExpression node
|
|
132
|
+
* @param {Object} checker - TypeScript type checker
|
|
133
|
+
* @param {Object} sourceFile - TypeScript source file
|
|
134
|
+
* @param {Object} customConfig - Custom configuration for custom extraction
|
|
135
|
+
* @returns {EventData}
|
|
136
|
+
*/
|
|
137
|
+
function extractCustomEvent(node, checker, sourceFile, customConfig) {
|
|
138
|
+
const args = node.arguments || [];
|
|
139
|
+
|
|
140
|
+
const eventArg = args[customConfig?.eventIndex ?? 0];
|
|
141
|
+
const propertiesArg = args[customConfig?.propertiesIndex ?? 1];
|
|
142
|
+
|
|
143
|
+
const eventName = getStringValue(eventArg, checker, sourceFile);
|
|
144
|
+
|
|
145
|
+
const extraArgs = {};
|
|
146
|
+
if (customConfig && customConfig.extraParams) {
|
|
147
|
+
customConfig.extraParams.forEach(extra => {
|
|
148
|
+
extraArgs[extra.name] = args[extra.idx];
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return { eventName, propertiesNode: propertiesArg, extraArgs };
|
|
153
|
+
}
|
|
154
|
+
|
|
124
155
|
/**
|
|
125
156
|
* Default event extraction for standard providers
|
|
126
157
|
* @param {Object} node - CallExpression node
|
|
@@ -134,7 +165,7 @@ function extractDefaultEvent(node, checker, sourceFile) {
|
|
|
134
165
|
}
|
|
135
166
|
|
|
136
167
|
// provider.track('event_name', { properties })
|
|
137
|
-
const eventName = getStringValue(node.arguments[0]);
|
|
168
|
+
const eventName = getStringValue(node.arguments[0], checker, sourceFile);
|
|
138
169
|
const propertiesNode = node.arguments[1];
|
|
139
170
|
|
|
140
171
|
return { eventName, propertiesNode };
|
|
@@ -149,9 +180,10 @@ function extractDefaultEvent(node, checker, sourceFile) {
|
|
|
149
180
|
* @param {string} functionName - Containing function name
|
|
150
181
|
* @param {Object} checker - TypeScript type checker
|
|
151
182
|
* @param {Object} sourceFile - TypeScript source file
|
|
183
|
+
* @param {Object} customConfig - Custom configuration for custom extraction
|
|
152
184
|
* @returns {Object|null} Processed event object or null
|
|
153
185
|
*/
|
|
154
|
-
function processEventData(eventData, source, filePath, line, functionName, checker, sourceFile) {
|
|
186
|
+
function processEventData(eventData, source, filePath, line, functionName, checker, sourceFile, customConfig) {
|
|
155
187
|
const { eventName, propertiesNode } = eventData;
|
|
156
188
|
|
|
157
189
|
if (!eventName || !propertiesNode) {
|
|
@@ -184,6 +216,37 @@ function processEventData(eventData, source, filePath, line, functionName, check
|
|
|
184
216
|
// Clean up any unresolved type markers
|
|
185
217
|
const cleanedProperties = cleanupProperties(properties);
|
|
186
218
|
|
|
219
|
+
// Handle custom extra params
|
|
220
|
+
if (source === 'custom' && customConfig && eventData.extraArgs) {
|
|
221
|
+
for (const [paramName, argNode] of Object.entries(eventData.extraArgs)) {
|
|
222
|
+
if (argNode && ts.isObjectLiteralExpression(argNode)) {
|
|
223
|
+
// Extract detailed properties from object literal expression
|
|
224
|
+
cleanedProperties[paramName] = {
|
|
225
|
+
type: 'object',
|
|
226
|
+
properties: extractProperties(checker, argNode)
|
|
227
|
+
};
|
|
228
|
+
} else if (argNode && ts.isIdentifier(argNode)) {
|
|
229
|
+
// Handle identifier references to objects
|
|
230
|
+
const resolvedNode = resolveIdentifierToInitializer(checker, argNode, sourceFile);
|
|
231
|
+
if (resolvedNode && ts.isObjectLiteralExpression(resolvedNode)) {
|
|
232
|
+
cleanedProperties[paramName] = {
|
|
233
|
+
type: 'object',
|
|
234
|
+
properties: extractProperties(checker, resolvedNode)
|
|
235
|
+
};
|
|
236
|
+
} else {
|
|
237
|
+
cleanedProperties[paramName] = {
|
|
238
|
+
type: inferNodeValueType(argNode)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
// For non-object arguments, use simple type inference
|
|
243
|
+
cleanedProperties[paramName] = {
|
|
244
|
+
type: inferNodeValueType(argNode)
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
187
250
|
return {
|
|
188
251
|
eventName,
|
|
189
252
|
source,
|
|
@@ -197,16 +260,121 @@ function processEventData(eventData, source, filePath, line, functionName, check
|
|
|
197
260
|
/**
|
|
198
261
|
* Gets string value from a TypeScript AST node
|
|
199
262
|
* @param {Object} node - TypeScript AST node
|
|
263
|
+
* @param {Object} checker - TypeScript type checker
|
|
264
|
+
* @param {Object} sourceFile - TypeScript source file
|
|
200
265
|
* @returns {string|null} String value or null
|
|
201
266
|
*/
|
|
202
|
-
function getStringValue(node) {
|
|
267
|
+
function getStringValue(node, checker, sourceFile) {
|
|
203
268
|
if (!node) return null;
|
|
269
|
+
|
|
270
|
+
// Handle string literals (existing behavior)
|
|
204
271
|
if (ts.isStringLiteral(node)) {
|
|
205
272
|
return node.text;
|
|
206
273
|
}
|
|
274
|
+
|
|
275
|
+
// Handle property access expressions like TRACKING_EVENTS.ECOMMERCE_PURCHASE
|
|
276
|
+
if (ts.isPropertyAccessExpression(node)) {
|
|
277
|
+
return resolvePropertyAccessToString(node, checker, sourceFile);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Handle identifiers that might reference constants
|
|
281
|
+
if (ts.isIdentifier(node)) {
|
|
282
|
+
return resolveIdentifierToString(node, checker, sourceFile);
|
|
283
|
+
}
|
|
284
|
+
|
|
207
285
|
return null;
|
|
208
286
|
}
|
|
209
287
|
|
|
288
|
+
/**
|
|
289
|
+
* Resolves a property access expression to its string value
|
|
290
|
+
* @param {Object} node - PropertyAccessExpression node
|
|
291
|
+
* @param {Object} checker - TypeScript type checker
|
|
292
|
+
* @param {Object} sourceFile - TypeScript source file
|
|
293
|
+
* @returns {string|null} String value or null
|
|
294
|
+
*/
|
|
295
|
+
function resolvePropertyAccessToString(node, checker, sourceFile) {
|
|
296
|
+
try {
|
|
297
|
+
// Get the symbol for the property access
|
|
298
|
+
const symbol = checker.getSymbolAtLocation(node);
|
|
299
|
+
if (!symbol || !symbol.valueDeclaration) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Check if it's a property assignment with a string initializer
|
|
304
|
+
if (ts.isPropertyAssignment(symbol.valueDeclaration) &&
|
|
305
|
+
symbol.valueDeclaration.initializer &&
|
|
306
|
+
ts.isStringLiteral(symbol.valueDeclaration.initializer)) {
|
|
307
|
+
return symbol.valueDeclaration.initializer.text;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Check if it's a variable declaration property
|
|
311
|
+
if (ts.isPropertySignature(symbol.valueDeclaration) ||
|
|
312
|
+
ts.isMethodSignature(symbol.valueDeclaration)) {
|
|
313
|
+
// Try to get the type and see if it's a string literal type
|
|
314
|
+
const type = checker.getTypeAtLocation(node);
|
|
315
|
+
if (type.isStringLiteral && type.isStringLiteral()) {
|
|
316
|
+
return type.value;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return null;
|
|
321
|
+
} catch (error) {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Resolves an identifier to its string value
|
|
328
|
+
* @param {Object} node - Identifier node
|
|
329
|
+
* @param {Object} checker - TypeScript type checker
|
|
330
|
+
* @param {Object} sourceFile - TypeScript source file
|
|
331
|
+
* @returns {string|null} String value or null
|
|
332
|
+
*/
|
|
333
|
+
function resolveIdentifierToString(node, checker, sourceFile) {
|
|
334
|
+
try {
|
|
335
|
+
const symbol = checker.getSymbolAtLocation(node);
|
|
336
|
+
if (!symbol) {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// First try to resolve through value declaration
|
|
341
|
+
if (symbol.valueDeclaration) {
|
|
342
|
+
const declaration = symbol.valueDeclaration;
|
|
343
|
+
|
|
344
|
+
// Handle variable declarations with string literal initializers
|
|
345
|
+
if (ts.isVariableDeclaration(declaration) &&
|
|
346
|
+
declaration.initializer &&
|
|
347
|
+
ts.isStringLiteral(declaration.initializer)) {
|
|
348
|
+
return declaration.initializer.text;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// Handle const declarations with object literals containing string properties
|
|
352
|
+
if (ts.isVariableDeclaration(declaration) &&
|
|
353
|
+
declaration.initializer &&
|
|
354
|
+
ts.isObjectLiteralExpression(declaration.initializer)) {
|
|
355
|
+
// This case is handled by property access resolution
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// If value declaration doesn't exist or doesn't help, try type resolution
|
|
361
|
+
// This handles imported constants that are resolved through TypeScript's type system
|
|
362
|
+
const type = checker.getTypeOfSymbolAtLocation(symbol, node);
|
|
363
|
+
if (type && type.isStringLiteral && typeof type.isStringLiteral === 'function' && type.isStringLiteral()) {
|
|
364
|
+
return type.value;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Alternative approach for string literal types (different TypeScript versions)
|
|
368
|
+
if (type && type.flags && (type.flags & ts.TypeFlags.StringLiteral)) {
|
|
369
|
+
return type.value;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return null;
|
|
373
|
+
} catch (error) {
|
|
374
|
+
return null;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
210
378
|
/**
|
|
211
379
|
* Finds a property by key in an ObjectLiteralExpression
|
|
212
380
|
* @param {Object} objectNode - ObjectLiteralExpression node
|
|
@@ -263,6 +431,16 @@ function cleanupProperties(properties) {
|
|
|
263
431
|
return cleaned;
|
|
264
432
|
}
|
|
265
433
|
|
|
434
|
+
function inferNodeValueType(node) {
|
|
435
|
+
if (!node) return 'any';
|
|
436
|
+
if (ts.isStringLiteral(node)) return 'string';
|
|
437
|
+
if (ts.isNumericLiteral(node)) return 'number';
|
|
438
|
+
if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) return 'boolean';
|
|
439
|
+
if (ts.isArrayLiteralExpression(node)) return 'array';
|
|
440
|
+
if (ts.isObjectLiteralExpression(node)) return 'object';
|
|
441
|
+
return 'any';
|
|
442
|
+
}
|
|
443
|
+
|
|
266
444
|
module.exports = {
|
|
267
445
|
extractEventData,
|
|
268
446
|
processEventData
|
|
@@ -34,6 +34,13 @@ function extractProperties(checker, node) {
|
|
|
34
34
|
const properties = {};
|
|
35
35
|
|
|
36
36
|
for (const prop of node.properties) {
|
|
37
|
+
// Handle spread assignments like {...object}
|
|
38
|
+
if (ts.isSpreadAssignment(prop)) {
|
|
39
|
+
const spreadProperties = extractSpreadProperties(checker, prop);
|
|
40
|
+
Object.assign(properties, spreadProperties);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
|
|
37
44
|
const key = getPropertyKey(prop);
|
|
38
45
|
if (!key) continue;
|
|
39
46
|
|
|
@@ -350,7 +357,7 @@ function resolveTypeSchema(checker, typeString) {
|
|
|
350
357
|
* @returns {string|null} Literal type or null
|
|
351
358
|
*/
|
|
352
359
|
function getLiteralType(node) {
|
|
353
|
-
if (!node) return null;
|
|
360
|
+
if (!node || typeof node.kind === 'undefined') return null;
|
|
354
361
|
if (ts.isStringLiteral(node)) return 'string';
|
|
355
362
|
if (ts.isNumericLiteral(node)) return 'number';
|
|
356
363
|
if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) return 'boolean';
|
|
@@ -371,6 +378,51 @@ function isArrayType(typeString) {
|
|
|
371
378
|
typeString.startsWith('readonly ');
|
|
372
379
|
}
|
|
373
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Extracts properties from a spread assignment
|
|
383
|
+
* @param {Object} checker - TypeScript type checker
|
|
384
|
+
* @param {Object} spreadNode - SpreadAssignment node
|
|
385
|
+
* @returns {Object.<string, PropertySchema>}
|
|
386
|
+
*/
|
|
387
|
+
function extractSpreadProperties(checker, spreadNode) {
|
|
388
|
+
if (!spreadNode.expression) {
|
|
389
|
+
return {};
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// If the spread is an identifier, resolve it to its declaration
|
|
393
|
+
if (ts.isIdentifier(spreadNode.expression)) {
|
|
394
|
+
const symbol = checker.getSymbolAtLocation(spreadNode.expression);
|
|
395
|
+
if (symbol && symbol.declarations && symbol.declarations.length > 0) {
|
|
396
|
+
const declaration = symbol.declarations[0];
|
|
397
|
+
|
|
398
|
+
// If it's a variable declaration with an object literal initializer
|
|
399
|
+
if (ts.isVariableDeclaration(declaration) && declaration.initializer) {
|
|
400
|
+
if (ts.isObjectLiteralExpression(declaration.initializer)) {
|
|
401
|
+
// Extract properties directly from the object literal
|
|
402
|
+
return extractProperties(checker, declaration.initializer);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Fallback to the original identifier schema extraction
|
|
408
|
+
const identifierSchema = extractIdentifierSchema(checker, spreadNode.expression);
|
|
409
|
+
return identifierSchema.properties || {};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// If the spread is an object literal, extract its properties
|
|
413
|
+
if (ts.isObjectLiteralExpression(spreadNode.expression)) {
|
|
414
|
+
return extractProperties(checker, spreadNode.expression);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// For other expressions, try to get the type and extract properties from it
|
|
418
|
+
try {
|
|
419
|
+
const spreadType = checker.getTypeAtLocation(spreadNode.expression);
|
|
420
|
+
return extractInterfaceProperties(checker, spreadType);
|
|
421
|
+
} catch (error) {
|
|
422
|
+
return {};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
374
426
|
/**
|
|
375
427
|
* Extracts properties from a TypeScript interface or type
|
|
376
428
|
* @param {Object} checker - TypeScript type checker
|
|
@@ -9,16 +9,14 @@ const { getProgram, findTrackingEvents, ProgramError, SourceFileError } = requir
|
|
|
9
9
|
* Analyzes a TypeScript file for analytics tracking calls
|
|
10
10
|
* @param {string} filePath - Path to the TypeScript file to analyze
|
|
11
11
|
* @param {Object} [program] - Optional existing TypeScript program to reuse
|
|
12
|
-
* @param {string} [
|
|
12
|
+
* @param {string} [customFunctionSignature] - Optional custom function signature to detect
|
|
13
13
|
* @returns {Array<Object>} Array of tracking events found in the file
|
|
14
14
|
*/
|
|
15
|
-
function analyzeTsFile(filePath, program,
|
|
16
|
-
const events = [];
|
|
17
|
-
|
|
15
|
+
function analyzeTsFile(filePath, program = null, customFunctionSignatures = null) {
|
|
18
16
|
try {
|
|
19
|
-
// Get or create TypeScript program
|
|
17
|
+
// Get or create TypeScript program (only once)
|
|
20
18
|
const tsProgram = getProgram(filePath, program);
|
|
21
|
-
|
|
19
|
+
|
|
22
20
|
// Get source file from program
|
|
23
21
|
const sourceFile = tsProgram.getSourceFile(filePath);
|
|
24
22
|
if (!sourceFile) {
|
|
@@ -28,9 +26,17 @@ function analyzeTsFile(filePath, program, customFunction) {
|
|
|
28
26
|
// Get type checker
|
|
29
27
|
const checker = tsProgram.getTypeChecker();
|
|
30
28
|
|
|
31
|
-
//
|
|
32
|
-
const
|
|
33
|
-
|
|
29
|
+
// Single-pass collection covering built-in + all custom configs
|
|
30
|
+
const events = findTrackingEvents(sourceFile, checker, filePath, customFunctionSignatures || []);
|
|
31
|
+
|
|
32
|
+
// Deduplicate events
|
|
33
|
+
const unique = new Map();
|
|
34
|
+
for (const evt of events) {
|
|
35
|
+
const key = `${evt.source}|${evt.eventName}|${evt.line}|${evt.functionName}`;
|
|
36
|
+
if (!unique.has(key)) unique.set(key, evt);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return Array.from(unique.values());
|
|
34
40
|
|
|
35
41
|
} catch (error) {
|
|
36
42
|
if (error instanceof ProgramError) {
|
|
@@ -42,7 +48,7 @@ function analyzeTsFile(filePath, program, customFunction) {
|
|
|
42
48
|
}
|
|
43
49
|
}
|
|
44
50
|
|
|
45
|
-
return
|
|
51
|
+
return [];
|
|
46
52
|
}
|
|
47
53
|
|
|
48
54
|
module.exports = { analyzeTsFile };
|
|
@@ -65,32 +65,55 @@ function getProgram(filePath, existingProgram) {
|
|
|
65
65
|
* @param {Object} sourceFile - TypeScript source file
|
|
66
66
|
* @param {Object} checker - TypeScript type checker
|
|
67
67
|
* @param {string} filePath - Path to the file being analyzed
|
|
68
|
-
* @param {
|
|
68
|
+
* @param {Array<Object>} [customConfigs] - Array of custom function configurations
|
|
69
69
|
* @returns {Array<Object>} Array of found events
|
|
70
70
|
*/
|
|
71
|
-
function findTrackingEvents(sourceFile, checker, filePath,
|
|
71
|
+
function findTrackingEvents(sourceFile, checker, filePath, customConfigs = []) {
|
|
72
72
|
const events = [];
|
|
73
73
|
|
|
74
74
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
75
|
+
* Helper to test if a CallExpression matches a custom function name.
|
|
76
|
+
* We simply rely on node.expression.getText() which preserves the fully qualified name.
|
|
77
77
|
*/
|
|
78
|
+
const matchesCustomFn = (callNode, fnName) => {
|
|
79
|
+
if (!fnName) return false;
|
|
80
|
+
try {
|
|
81
|
+
return callNode.expression && callNode.expression.getText() === fnName;
|
|
82
|
+
} catch {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
78
87
|
function visit(node) {
|
|
79
88
|
try {
|
|
80
89
|
if (ts.isCallExpression(node)) {
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
90
|
+
let matchedCustom = null;
|
|
91
|
+
|
|
92
|
+
if (Array.isArray(customConfigs) && customConfigs.length > 0) {
|
|
93
|
+
for (const cfg of customConfigs) {
|
|
94
|
+
if (cfg && matchesCustomFn(node, cfg.functionName)) {
|
|
95
|
+
matchedCustom = cfg;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
84
99
|
}
|
|
100
|
+
|
|
101
|
+
const event = extractTrackingEvent(
|
|
102
|
+
node,
|
|
103
|
+
sourceFile,
|
|
104
|
+
checker,
|
|
105
|
+
filePath,
|
|
106
|
+
matchedCustom /* may be null */
|
|
107
|
+
);
|
|
108
|
+
if (event) events.push(event);
|
|
85
109
|
}
|
|
86
|
-
|
|
110
|
+
|
|
87
111
|
ts.forEachChild(node, visit);
|
|
88
112
|
} catch (error) {
|
|
89
113
|
console.error(`Error processing node in ${filePath}:`, error.message);
|
|
90
114
|
}
|
|
91
115
|
}
|
|
92
116
|
|
|
93
|
-
// Start traversal from the root
|
|
94
117
|
ts.forEachChild(sourceFile, visit);
|
|
95
118
|
|
|
96
119
|
return events;
|
|
@@ -102,25 +125,25 @@ function findTrackingEvents(sourceFile, checker, filePath, customFunction) {
|
|
|
102
125
|
* @param {Object} sourceFile - TypeScript source file
|
|
103
126
|
* @param {Object} checker - TypeScript type checker
|
|
104
127
|
* @param {string} filePath - File path
|
|
105
|
-
* @param {
|
|
128
|
+
* @param {Object} [customConfig] - Custom function configuration
|
|
106
129
|
* @returns {Object|null} Extracted event or null
|
|
107
130
|
*/
|
|
108
|
-
function extractTrackingEvent(node, sourceFile, checker, filePath,
|
|
131
|
+
function extractTrackingEvent(node, sourceFile, checker, filePath, customConfig) {
|
|
109
132
|
// Detect the analytics source
|
|
110
|
-
const source = detectAnalyticsSource(node,
|
|
133
|
+
const source = detectAnalyticsSource(node, customConfig?.functionName);
|
|
111
134
|
if (source === 'unknown') {
|
|
112
135
|
return null;
|
|
113
136
|
}
|
|
114
137
|
|
|
115
138
|
// Extract event data based on the source
|
|
116
|
-
const eventData = extractEventData(node, source, checker, sourceFile);
|
|
139
|
+
const eventData = extractEventData(node, source, checker, sourceFile, customConfig);
|
|
117
140
|
|
|
118
141
|
// Get location and context information
|
|
119
142
|
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
|
|
120
143
|
const functionName = findWrappingFunction(node);
|
|
121
144
|
|
|
122
145
|
// Process the event data into final format
|
|
123
|
-
return processEventData(eventData, source, filePath, line, functionName, checker, sourceFile);
|
|
146
|
+
return processEventData(eventData, source, filePath, line, functionName, checker, sourceFile, customConfig);
|
|
124
147
|
}
|
|
125
148
|
|
|
126
149
|
module.exports = {
|
|
@@ -110,6 +110,16 @@ function findParentFunctionName(node) {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
// Property declaration in class: myFunc = () => {}
|
|
114
|
+
if (ts.isPropertyDeclaration(parent) && parent.name) {
|
|
115
|
+
if (ts.isIdentifier(parent.name)) {
|
|
116
|
+
return parent.name.escapedText;
|
|
117
|
+
}
|
|
118
|
+
if (ts.isStringLiteral(parent.name)) {
|
|
119
|
+
return parent.name.text;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
113
123
|
// Method property in object literal: { myFunc() {} }
|
|
114
124
|
if (ts.isMethodDeclaration(parent) && parent.name) {
|
|
115
125
|
return parent.name.escapedText;
|
|
@@ -117,6 +127,7 @@ function findParentFunctionName(node) {
|
|
|
117
127
|
|
|
118
128
|
// Binary expression assignment: obj.myFunc = () => {}
|
|
119
129
|
if (ts.isBinaryExpression(parent) &&
|
|
130
|
+
parent.operatorToken &&
|
|
120
131
|
parent.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
|
121
132
|
if (ts.isPropertyAccessExpression(parent.left)) {
|
|
122
133
|
return parent.left.name.escapedText;
|
|
@@ -144,7 +144,7 @@ function isCustomType(typeString) {
|
|
|
144
144
|
* @returns {string} Basic type string
|
|
145
145
|
*/
|
|
146
146
|
function getBasicTypeOfArrayElement(checker, element) {
|
|
147
|
-
if (!element) return 'any';
|
|
147
|
+
if (!element || typeof element.kind === 'undefined') return 'any';
|
|
148
148
|
|
|
149
149
|
// Check for literal values first
|
|
150
150
|
if (ts.isStringLiteral(element)) {
|