Skip to content

Commit 5a3ec20

Browse files
cursoragentskarim
andcommitted
Enhance TypeScript tracking detection with improved function and event matching
Co-authored-by: sameenkarim <[email protected]>
1 parent c5cb9b2 commit 5a3ec20

File tree

7 files changed

+242
-25
lines changed

7 files changed

+242
-25
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"scripts": {
1010
"test": "node tests",
1111
"test:js": "node --test tests/analyzeJavaScript.test.js",
12-
"test:ts": "node --test tests/analyzeTypeScript.test.js",
12+
"test:ts": "node --test tests/analyzeTypeScript.test.js tests/trackUserEvent.test.js",
1313
"test:python": "node --experimental-vm-modules --test tests/analyzePython.test.js",
1414
"test:ruby": "node --experimental-vm-modules --test tests/analyzeRuby.test.js",
1515
"test:go": "node --test tests/analyzeGo.test.js",

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,19 @@ function isCustomFunction(node, customFunction) {
5050

5151
// Simple identifier (no dot)
5252
if (parts.length === 1) {
53-
return node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === customFunction;
53+
// If callee is a simple identifier, match directly
54+
if (node.callee.type === NODE_TYPES.IDENTIFIER && node.callee.name === customFunction) {
55+
return true;
56+
}
57+
58+
// If callee is a MemberExpression, allow matching on the right-most property when it matches
59+
if (node.callee.type === NODE_TYPES.MEMBER_EXPRESSION &&
60+
node.callee.property.type === NODE_TYPES.IDENTIFIER &&
61+
node.callee.property.name === customFunction) {
62+
return true;
63+
}
64+
65+
return false;
5466
}
5567

5668
// For dot-separated names, the callee should be a MemberExpression chain.
@@ -91,6 +103,9 @@ function matchesMemberChain(memberExpr, parts) {
91103
} else if (currentNode.type === NODE_TYPES.IDENTIFIER) {
92104
// We reached the leftmost Identifier; it should match the first part
93105
return idx === 0 && currentNode.name === expectedPart;
106+
} else if (currentNode.type === 'ThisExpression') {
107+
// Support chains starting with "this" (e.g., this.props.trackUserEvent)
108+
return idx === 0 && expectedPart === 'this';
94109
} else {
95110
// Unexpected node type (e.g., ThisExpression, CallExpression, etc.)
96111
return false;

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

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,34 @@ function detectAnalyticsSource(node, customFunction) {
4444
* @returns {boolean}
4545
*/
4646
function isCustomFunction(node, customFunction) {
47+
if (!customFunction) return false;
48+
49+
// ------------------------------------------------------------------
50+
// If the customFunction string does not include a dot, treat it as a
51+
// short name and match calls where the right-most property (or the
52+
// identifier itself) equals the name. This covers patterns like:
53+
// trackUserEvent(...)
54+
// this.props.trackUserEvent(...)
55+
// ------------------------------------------------------------------
56+
if (!customFunction.includes('.')) {
57+
// Direct identifier call
58+
if (ts.isIdentifier(node.expression) && node.expression.escapedText === customFunction) {
59+
return true;
60+
}
61+
62+
// PropertyAccessExpression – check the final name
63+
if (ts.isPropertyAccessExpression(node.expression) && node.expression.name.escapedText === customFunction) {
64+
return true;
65+
}
66+
67+
return false;
68+
}
69+
70+
// ------------------------------------------------------------------
71+
// For fully qualified names (with dots), we rely on exact text match –
72+
// this keeps behaviour unchanged for signatures like
73+
// "CustomModule.track".
74+
// ------------------------------------------------------------------
4775
const canBeCustomFunction = ts.isIdentifier(node.expression) ||
4876
ts.isPropertyAccessExpression(node.expression) ||
4977
ts.isCallExpression(node.expression) || // For chained calls like getTracker().track()

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

Lines changed: 74 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -186,26 +186,27 @@ function extractDefaultEvent(node, checker, sourceFile) {
186186
function processEventData(eventData, source, filePath, line, functionName, checker, sourceFile, customConfig) {
187187
const { eventName, propertiesNode } = eventData;
188188

189-
if (!eventName || !propertiesNode) {
189+
if (!eventName) {
190190
return null;
191191
}
192192

193193
let properties = null;
194194

195195
// Check if properties is an object literal
196-
if (ts.isObjectLiteralExpression(propertiesNode)) {
196+
if (propertiesNode && ts.isObjectLiteralExpression(propertiesNode)) {
197197
properties = extractProperties(checker, propertiesNode);
198198
}
199199
// Check if properties is an identifier (variable reference)
200-
else if (ts.isIdentifier(propertiesNode)) {
200+
else if (propertiesNode && ts.isIdentifier(propertiesNode)) {
201201
const resolvedNode = resolveIdentifierToInitializer(checker, propertiesNode, sourceFile);
202202
if (resolvedNode && ts.isObjectLiteralExpression(resolvedNode)) {
203203
properties = extractProperties(checker, resolvedNode);
204204
}
205205
}
206206

207207
if (!properties) {
208-
return null;
208+
// If propertiesNode was missing or could not be resolved, default to empty object
209+
properties = {};
209210
}
210211

211212
// Special handling for Snowplow: remove 'action' from properties
@@ -296,27 +297,78 @@ function resolvePropertyAccessToString(node, checker, sourceFile) {
296297
try {
297298
// Get the symbol for the property access
298299
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;
300+
if (symbol && symbol.valueDeclaration) {
301+
// Check if it's a property assignment with a string initializer
302+
if (ts.isPropertyAssignment(symbol.valueDeclaration) &&
303+
symbol.valueDeclaration.initializer &&
304+
ts.isStringLiteral(symbol.valueDeclaration.initializer)) {
305+
return symbol.valueDeclaration.initializer.text;
306+
}
307+
308+
// Check if it's a variable declaration property
309+
if (ts.isPropertySignature(symbol.valueDeclaration) ||
310+
ts.isMethodSignature(symbol.valueDeclaration)) {
311+
const type = checker.getTypeAtLocation(node);
312+
if (type && type.isStringLiteral && type.isStringLiteral()) {
313+
return type.value;
314+
}
315+
}
316+
// If we haven't returned by now, fall through to the manual fallback below.
308317
}
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;
318+
319+
// ---------------------------------------------------------------------
320+
// Fallback – manually resolve patterns like:
321+
// const CONST = { KEY: 'value' };
322+
// const CONST = Object.freeze({ KEY: 'value' });
323+
// And later accessed as CONST.KEY
324+
// ---------------------------------------------------------------------
325+
326+
if (ts.isIdentifier(node.expression)) {
327+
const objIdentifier = node.expression;
328+
const initializer = resolveIdentifierToInitializer(checker, objIdentifier, sourceFile);
329+
330+
if (initializer) {
331+
let objectLiteral = null;
332+
333+
// Direct object literal: const CONST = { KEY: 'value' }
334+
if (ts.isObjectLiteralExpression(initializer)) {
335+
objectLiteral = initializer;
336+
}
337+
// Object.freeze pattern: const CONST = Object.freeze({ KEY: 'value' })
338+
else if (ts.isCallExpression(initializer)) {
339+
const callee = initializer.expression;
340+
if (
341+
ts.isPropertyAccessExpression(callee) &&
342+
ts.isIdentifier(callee.expression) &&
343+
callee.expression.escapedText === 'Object' &&
344+
callee.name.escapedText === 'freeze' &&
345+
initializer.arguments.length > 0 &&
346+
ts.isObjectLiteralExpression(initializer.arguments[0])
347+
) {
348+
objectLiteral = initializer.arguments[0];
349+
}
350+
}
351+
352+
if (objectLiteral) {
353+
const propNode = findPropertyByKey(objectLiteral, node.name.escapedText || node.name.text);
354+
if (propNode && propNode.initializer && ts.isStringLiteral(propNode.initializer)) {
355+
return propNode.initializer.text;
356+
}
357+
}
317358
}
318359
}
319-
360+
361+
// Final fallback – attempt to use type information (works for imported frozen constants)
362+
try {
363+
const t = checker.getTypeAtLocation(node);
364+
if (t && t.isStringLiteral && typeof t.isStringLiteral === 'function' && t.isStringLiteral()) {
365+
return t.value;
366+
}
367+
if (t && t.flags && (t.flags & ts.TypeFlags.StringLiteral)) {
368+
return t.value;
369+
}
370+
} catch (_) {/* ignore */}
371+
320372
return null;
321373
} catch (error) {
322374
return null;

src/analyze/typescript/parser.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,23 @@ function findTrackingEvents(sourceFile, checker, filePath, customConfigs = []) {
7878
const matchesCustomFn = (callNode, fnName) => {
7979
if (!fnName) return false;
8080
try {
81-
return callNode.expression && callNode.expression.getText() === fnName;
81+
const exprText = callNode.expression?.getText?.();
82+
if (!exprText) return false;
83+
84+
// Exact match (existing behaviour)
85+
if (exprText === fnName) return true;
86+
87+
// If fnName is a short identifier (no dot), allow matching trailing property but
88+
// only for patterns that include `this.` or `.props.` in the chain to avoid
89+
// over-matching generic calls like analytics.track
90+
if (!fnName.includes('.')) {
91+
if (exprText.endsWith(`.${fnName}`)) {
92+
return exprText.startsWith('this.') || exprText.includes('.props.');
93+
}
94+
return false;
95+
}
96+
97+
return false;
8298
} catch {
8399
return false;
84100
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import React, { useEffect } from 'react';
2+
3+
// Global declarations (simplified for fixture)
4+
declare function trackUserEvent(eventName: string, properties?: Record<string, any>): void;
5+
6+
declare global {
7+
interface PropsWithTracker {
8+
trackUserEvent: typeof trackUserEvent;
9+
}
10+
}
11+
12+
export const TELEMETRY_EVENTS = Object.freeze({
13+
VIEWED_PAGE: 'ViewedPage',
14+
CLICKED_CTA: 'ClickedCTA',
15+
VIEWED_DASHBOARD: 'ViewedDashboard'
16+
});
17+
18+
// -----------------------------------------------------------------------------
19+
// Functional component – direct identifier usage of trackUserEvent
20+
// -----------------------------------------------------------------------------
21+
export const SampleComponent: React.FC = () => {
22+
useEffect(() => {
23+
// Direct call
24+
trackUserEvent(TELEMETRY_EVENTS.VIEWED_PAGE, {
25+
path: window.location.pathname
26+
});
27+
}, []);
28+
29+
const handleClick = () => {
30+
trackUserEvent(TELEMETRY_EVENTS.CLICKED_CTA, { label: 'Start Now' });
31+
};
32+
33+
return <button onClick={handleClick}>Click me</button>;
34+
};
35+
36+
// -----------------------------------------------------------------------------
37+
// Class component – this.props.trackUserEvent usage
38+
// -----------------------------------------------------------------------------
39+
interface DashboardProps {
40+
trackUserEvent: typeof trackUserEvent;
41+
}
42+
43+
export class Dashboard extends React.Component<DashboardProps> {
44+
componentDidMount(): void {
45+
// Member expression chain starting with this.props
46+
this.props.trackUserEvent(TELEMETRY_EVENTS.VIEWED_DASHBOARD);
47+
}
48+
49+
render() {
50+
return <div>Dashboard</div>;
51+
}
52+
}

tests/trackUserEvent.test.js

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
const test = require('node:test');
2+
const assert = require('node:assert');
3+
const path = require('path');
4+
5+
const { analyzeTsFile } = require('../src/analyze/typescript');
6+
const { getProgram } = require('../src/analyze/typescript/parser');
7+
const { parseCustomFunctionSignature } = require('../src/analyze/utils/customFunctionParser');
8+
9+
// Helper to create minimal program
10+
function createProgram(filePath) {
11+
return getProgram(filePath);
12+
}
13+
14+
test.describe('trackUserEvent custom tracking function patterns', () => {
15+
const fixturesDir = path.join(__dirname, 'fixtures', 'typescript-react');
16+
const filePath = path.join(fixturesDir, 'track-user-event.tsx');
17+
18+
test('should detect trackUserEvent calls in various patterns', () => {
19+
const program = createProgram(filePath);
20+
21+
// Provide two signatures: short name and member expression including this.props
22+
const customFunctionSignatures = [
23+
parseCustomFunctionSignature('trackUserEvent(EVENT_NAME, PROPERTIES)')
24+
];
25+
26+
const events = analyzeTsFile(filePath, program, customFunctionSignatures);
27+
28+
// We expect three events corresponding to the fixture
29+
assert.strictEqual(events.length, 3);
30+
31+
const viewedPage = events.find(e => e.eventName === 'ViewedPage');
32+
assert.ok(viewedPage);
33+
assert.strictEqual(viewedPage.functionName, 'useEffect()');
34+
assert.strictEqual(viewedPage.source, 'custom');
35+
assert.deepStrictEqual(viewedPage.properties, {
36+
path: { type: 'string' }
37+
});
38+
39+
const clickedCta = events.find(e => e.eventName === 'ClickedCTA');
40+
assert.ok(clickedCta);
41+
assert.strictEqual(clickedCta.functionName, 'handleClick');
42+
assert.strictEqual(clickedCta.source, 'custom');
43+
assert.deepStrictEqual(clickedCta.properties, {
44+
label: { type: 'string' }
45+
});
46+
47+
const viewedDashboard = events.find(e => e.eventName === 'ViewedDashboard');
48+
assert.ok(viewedDashboard);
49+
assert.strictEqual(viewedDashboard.functionName, 'componentDidMount');
50+
assert.strictEqual(viewedDashboard.source, 'custom');
51+
// No properties passed, should be empty object
52+
assert.deepStrictEqual(viewedDashboard.properties, {});
53+
});
54+
});

0 commit comments

Comments
 (0)