Skip to content

Commit f5a1755

Browse files
committed
support chained fns for custom tracking
1 parent c2cbbc0 commit f5a1755

File tree

16 files changed

+506
-50
lines changed

16 files changed

+506
-50
lines changed

src/analyze/javascript/detectors/analytics-source.js

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -45,20 +45,23 @@ function detectAnalyticsSource(node, customFunction) {
4545
function isCustomFunction(node, customFunction) {
4646
if (!customFunction) return false;
4747

48-
// Support dot-separated names like "CustomModule.track"
49-
const parts = customFunction.split('.');
48+
// Support dot-separated names like "CustomModule.track" and chained calls like "getTrackingService().track"
49+
// Normalize each segment by stripping trailing parentheses
50+
const parts = customFunction.split('.').map(p => p.replace(/\(\s*\)$/, ''));
5051

5152
// Simple identifier (no dot)
5253
if (parts.length === 1) {
53-
return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === customFunction;
54+
return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === parts[0];
5455
}
5556

56-
// For dot-separated names, the callee should be a MemberExpression chain.
57-
if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
57+
// For dot-separated names, the callee should be a MemberExpression chain,
58+
// but we also allow CallExpression in the chain (e.g., getService().track)
59+
const callee = node.callee;
60+
if (callee.type !== NODE_TYPES.MEMBER_EXPRESSION && callee.type !== NODE_TYPES.CALL_EXPRESSION) {
5861
return false;
5962
}
6063

61-
return matchesMemberChain(node.callee, parts);
64+
return matchesMemberChain(callee, parts);
6265
}
6366

6467
/**
@@ -75,26 +78,34 @@ function matchesMemberChain(memberExpr, parts) {
7578
while (currentNode && idx >= 0) {
7679
const expectedPart = parts[idx];
7780

78-
// property should match current expectedPart
7981
if (currentNode.type === NODE_TYPES.MEMBER_EXPRESSION) {
80-
// Ensure property is Identifier and matches
82+
// Ensure property is Identifier and matches the expected part
8183
if (
8284
currentNode.property.type !== NODE_TYPES.IDENTIFIER ||
8385
currentNode.property.name !== expectedPart
8486
) {
8587
return false;
8688
}
8789

88-
// Move to the object of the MemberExpression
90+
// Move to the object (which could itself be a MemberExpression, Identifier, or CallExpression)
8991
currentNode = currentNode.object;
9092
idx -= 1;
91-
} else if (currentNode.type === NODE_TYPES.IDENTIFIER) {
92-
// We reached the leftmost Identifier; it should match the first part
93+
continue;
94+
}
95+
96+
// If we encounter a CallExpression in the chain (e.g., getService().track),
97+
// step into its callee without consuming an expected part.
98+
if (currentNode.type === NODE_TYPES.CALL_EXPRESSION) {
99+
currentNode = currentNode.callee;
100+
continue;
101+
}
102+
103+
if (currentNode.type === NODE_TYPES.IDENTIFIER) {
93104
return idx === 0 && currentNode.name === expectedPart;
94-
} else {
95-
// Unexpected node type (e.g., ThisExpression, CallExpression, etc.)
96-
return false;
97105
}
106+
107+
// Unexpected node type (e.g., ThisExpression, Literal, etc.)
108+
return false;
98109
}
99110

100111
return false;

src/analyze/javascript/extractors/event-extractor.js

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,11 @@ function getStringValue(node, constantMap = {}) {
257257
return node.value;
258258
}
259259
if (node.type === NODE_TYPES.MEMBER_EXPRESSION) {
260-
return resolveMemberExpressionToString(node, constantMap);
260+
const resolved = resolveMemberExpressionToString(node, constantMap);
261+
if (resolved) return resolved;
262+
// Fallback: return a dotted path for member expressions when we cannot
263+
// resolve to a literal (e.g., imported constants like TELEMETRY_EVENTS.X)
264+
return memberExpressionToPath(node);
261265
}
262266
return null;
263267
}
@@ -315,6 +319,25 @@ function resolveMemberExpressionToString(node, constantMap) {
315319
return null;
316320
}
317321

322+
// Build a dotted path string for a MemberExpression (e.g., OBJ.KEY.SUBKEY)
323+
function memberExpressionToPath(node) {
324+
if (!node || node.type !== NODE_TYPES.MEMBER_EXPRESSION) return null;
325+
const parts = [];
326+
let current = node;
327+
while (current && current.type === NODE_TYPES.MEMBER_EXPRESSION && !current.computed) {
328+
if (current.property && current.property.type === NODE_TYPES.IDENTIFIER) {
329+
parts.unshift(current.property.name);
330+
} else if (current.property && current.property.type === NODE_TYPES.LITERAL) {
331+
parts.unshift(String(current.property.value));
332+
}
333+
current = current.object;
334+
}
335+
if (current && current.type === NODE_TYPES.IDENTIFIER) {
336+
parts.unshift(current.name);
337+
}
338+
return parts.length ? parts.join('.') : null;
339+
}
340+
318341
module.exports = {
319342
extractEventData,
320343
processEventData

src/analyze/javascript/parser.js

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const { PARSER_OPTIONS, NODE_TYPES } = require('./constants');
1212
const { detectAnalyticsSource } = require('./detectors');
1313
const { extractEventData, processEventData } = require('./extractors');
1414
const { findWrappingFunction } = require('./utils/function-finder');
15+
const { collectImportedConstantStringMap } = require('./utils/import-resolver');
1516

1617
// Extend walker to support JSX
1718
extend(walk.base);
@@ -82,19 +83,15 @@ function parseFile(filePath) {
8283
function nodeMatchesCustomFunction(node, fnName) {
8384
if (!fnName || !node.callee) return false;
8485

85-
const parts = fnName.split('.');
86+
// Support chained calls in function name by stripping trailing parens from each segment
87+
const parts = fnName.split('.').map(p => p.replace(/\(\s*\)$/, ''));
8688

8789
// Simple identifier case
8890
if (parts.length === 1) {
89-
return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === fnName;
91+
return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === parts[0];
9092
}
9193

92-
// Member expression chain case
93-
if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) {
94-
return false;
95-
}
96-
97-
// Walk the chain from the right-most property to the leftmost object
94+
// Allow MemberExpression and CallExpression within the chain (e.g., getService().track)
9895
let currentNode = node.callee;
9996
let idx = parts.length - 1;
10097

@@ -108,13 +105,23 @@ function nodeMatchesCustomFunction(node, fnName) {
108105
) {
109106
return false;
110107
}
108+
// step to the object; do not decrement idx for call expressions yet
111109
currentNode = currentNode.object;
112110
idx -= 1;
113-
} else if (currentNode.type === NODE_TYPES.IDENTIFIER) {
111+
continue;
112+
}
113+
114+
if (currentNode.type === NODE_TYPES.CALL_EXPRESSION) {
115+
// descend into the callee of the call without consuming a part
116+
currentNode = currentNode.callee;
117+
continue;
118+
}
119+
120+
if (currentNode.type === NODE_TYPES.IDENTIFIER) {
114121
return idx === 0 && currentNode.name === expected;
115-
} else {
116-
return false;
117122
}
123+
124+
return false;
118125
}
119126

120127
return false;
@@ -183,8 +190,11 @@ function collectConstantStringMap(ast) {
183190
function findTrackingEvents(ast, filePath, customConfigs = []) {
184191
const events = [];
185192

186-
// Collect constant mappings once per file
187-
const constantMap = collectConstantStringMap(ast);
193+
// Collect constant mappings once per file (locals + imported)
194+
const constantMap = {
195+
...collectConstantStringMap(ast),
196+
...collectImportedConstantStringMap(filePath, ast)
197+
};
188198

189199
walk.ancestor(ast, {
190200
[NODE_TYPES.CALL_EXPRESSION]: (node, ancestors) => {

0 commit comments

Comments
 (0)