Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 44 additions & 14 deletions src/analyze/javascript/extractors/event-extractor.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,29 +28,31 @@ const EXTRACTION_STRATEGIES = {
* Extracts event information from a CallExpression node
* @param {Object} node - AST CallExpression node
* @param {string} source - Analytics provider source
* @param {Object} constantMap - Collected constant map
* @param {Object} customConfig - Parsed custom function configuration
* @returns {EventData} Extracted event data
*/
function extractEventData(node, source, customConfig) {
function extractEventData(node, source, constantMap = {}, customConfig) {
const strategy = EXTRACTION_STRATEGIES[source] || EXTRACTION_STRATEGIES.default;
if (source === 'custom') {
return strategy(node, customConfig);
return strategy(node, constantMap, customConfig);
}
return strategy(node);
return strategy(node, constantMap);
}

/**
* Extracts Google Analytics event data
* @param {Object} node - CallExpression node
* @param {Object} constantMap - Collected constant map
* @returns {EventData}
*/
function extractGoogleAnalyticsEvent(node) {
function extractGoogleAnalyticsEvent(node, constantMap) {
if (!node.arguments || node.arguments.length < 3) {
return { eventName: null, propertiesNode: null };
}

// gtag('event', 'event_name', { properties })
const eventName = getStringValue(node.arguments[1]);
const eventName = getStringValue(node.arguments[1], constantMap);
const propertiesNode = node.arguments[2];

return { eventName, propertiesNode };
Expand All @@ -59,9 +61,10 @@ function extractGoogleAnalyticsEvent(node) {
/**
* Extracts Snowplow event data
* @param {Object} node - CallExpression node
* @param {Object} constantMap - Collected constant map
* @returns {EventData}
*/
function extractSnowplowEvent(node) {
function extractSnowplowEvent(node, constantMap) {
if (!node.arguments || node.arguments.length === 0) {
return { eventName: null, propertiesNode: null };
}
Expand All @@ -75,7 +78,7 @@ function extractSnowplowEvent(node) {

if (structEventArg.type === NODE_TYPES.OBJECT_EXPRESSION) {
const actionProperty = findPropertyByKey(structEventArg, 'action');
const eventName = actionProperty ? getStringValue(actionProperty.value) : null;
const eventName = actionProperty ? getStringValue(actionProperty.value, constantMap) : null;

return { eventName, propertiesNode: structEventArg };
}
Expand All @@ -87,15 +90,16 @@ function extractSnowplowEvent(node) {
/**
* Extracts mParticle event data
* @param {Object} node - CallExpression node
* @param {Object} constantMap - Collected constant map
* @returns {EventData}
*/
function extractMparticleEvent(node) {
function extractMparticleEvent(node, constantMap) {
if (!node.arguments || node.arguments.length < 3) {
return { eventName: null, propertiesNode: null };
}

// mParticle.logEvent('event_name', mParticle.EventType.Navigation, { properties })
const eventName = getStringValue(node.arguments[0]);
const eventName = getStringValue(node.arguments[0], constantMap);
const propertiesNode = node.arguments[2];

return { eventName, propertiesNode };
Expand All @@ -104,15 +108,16 @@ function extractMparticleEvent(node) {
/**
* Default event extraction for standard providers
* @param {Object} node - CallExpression node
* @param {Object} constantMap - Collected constant map
* @returns {EventData}
*/
function extractDefaultEvent(node) {
function extractDefaultEvent(node, constantMap) {
if (!node.arguments || node.arguments.length < 2) {
return { eventName: null, propertiesNode: null };
}

// provider.track('event_name', { properties })
const eventName = getStringValue(node.arguments[0]);
const eventName = getStringValue(node.arguments[0], constantMap);
const propertiesNode = node.arguments[1];

return { eventName, propertiesNode };
Expand All @@ -121,16 +126,17 @@ function extractDefaultEvent(node) {
/**
* Extracts Custom function event data according to signature
* @param {Object} node - CallExpression node
* @param {Object} constantMap - Collected constant map
* @param {Object} customConfig - Parsed custom function configuration
* @returns {EventData & {extraArgs:Object}} event data plus extra args map
*/
function extractCustomEvent(node, customConfig) {
function extractCustomEvent(node, constantMap, customConfig) {
const args = node.arguments || [];

const eventArg = args[customConfig?.eventIndex ?? 0];
const propertiesArg = args[customConfig?.propertiesIndex ?? 1];

const eventName = getStringValue(eventArg);
const eventName = getStringValue(eventArg, constantMap);

const extraArgs = {};
if (customConfig && customConfig.extraParams) {
Expand Down Expand Up @@ -197,13 +203,17 @@ function processEventData(eventData, source, filePath, line, functionName, custo
/**
* Gets string value from an AST node
* @param {Object} node - AST node
* @param {Object} constantMap - Collected constant map
* @returns {string|null} String value or null
*/
function getStringValue(node) {
function getStringValue(node, constantMap = {}) {
if (!node) return null;
if (node.type === NODE_TYPES.LITERAL && typeof node.value === 'string') {
return node.value;
}
if (node.type === NODE_TYPES.MEMBER_EXPRESSION) {
return resolveMemberExpressionToString(node, constantMap);
}
return null;
}

Expand Down Expand Up @@ -240,6 +250,26 @@ function inferNodeValueType(node) {
}
}

// Helper to resolve MemberExpression (CONST.KEY) to string using collected constant map
function resolveMemberExpressionToString(node, constantMap) {
if (!node || node.type !== NODE_TYPES.MEMBER_EXPRESSION) return null;
if (node.computed) return null; // Only support dot notation

const object = node.object;
const property = node.property;

if (object.type !== NODE_TYPES.IDENTIFIER) return null;
if (property.type !== NODE_TYPES.IDENTIFIER) return null;

const objName = object.name;
const propName = property.name;

if (constantMap && constantMap[objName] && typeof constantMap[objName][propName] === 'string') {
return constantMap[objName][propName];
}
return null;
}

module.exports = {
extractEventData,
processEventData
Expand Down
72 changes: 59 additions & 13 deletions src/analyze/javascript/parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,57 @@ function nodeMatchesCustomFunction(node, fnName) {
return false;
}

// -----------------------------------------------------------------------------
// Utility – collect constants defined as plain objects or Object.freeze({...})
// -----------------------------------------------------------------------------
function collectConstantStringMap(ast) {
const map = {};

walk.simple(ast, {
VariableDeclaration(node) {
// Only consider const declarations
if (node.kind !== 'const') return;
node.declarations.forEach(decl => {
if (decl.id.type !== NODE_TYPES.IDENTIFIER || !decl.init) return;
const name = decl.id.name;
let objLiteral = null;

if (decl.init.type === NODE_TYPES.OBJECT_EXPRESSION) {
objLiteral = decl.init;
} else if (decl.init.type === NODE_TYPES.CALL_EXPRESSION) {
// Check for Object.freeze({...})
const callee = decl.init.callee;
if (
callee &&
callee.type === NODE_TYPES.MEMBER_EXPRESSION &&
callee.object.type === NODE_TYPES.IDENTIFIER &&
callee.object.name === 'Object' &&
callee.property.type === NODE_TYPES.IDENTIFIER &&
callee.property.name === 'freeze' &&
decl.init.arguments.length > 0 &&
decl.init.arguments[0].type === NODE_TYPES.OBJECT_EXPRESSION
) {
objLiteral = decl.init.arguments[0];
}
}

if (objLiteral) {
map[name] = {};
objLiteral.properties.forEach(prop => {
if (!prop.key || !prop.value) return;
const keyName = prop.key.name || prop.key.value;
if (prop.value.type === NODE_TYPES.LITERAL && typeof prop.value.value === 'string') {
map[name][keyName] = prop.value.value;
}
});
}
});
}
});

return map;
}

/**
* Walk the AST once and find tracking events for built-in providers plus any number of custom
* function configurations. This avoids the previous O(n * customConfigs) behaviour.
Expand All @@ -132,6 +183,9 @@ function nodeMatchesCustomFunction(node, fnName) {
function findTrackingEvents(ast, filePath, customConfigs = []) {
const events = [];

// Collect constant mappings once per file
const constantMap = collectConstantStringMap(ast);

walk.ancestor(ast, {
[NODE_TYPES.CALL_EXPRESSION]: (node, ancestors) => {
try {
Expand All @@ -148,12 +202,10 @@ function findTrackingEvents(ast, filePath, customConfigs = []) {
}

if (matchedCustomConfig) {
// Force source to 'custom' and use matched config
const event = extractTrackingEvent(node, ancestors, filePath, matchedCustomConfig);
const event = extractTrackingEvent(node, ancestors, filePath, constantMap, matchedCustomConfig);
if (event) events.push(event);
} else {
// Let built-in detector figure out source (pass undefined customFunction)
const event = extractTrackingEvent(node, ancestors, filePath, null);
const event = extractTrackingEvent(node, ancestors, filePath, constantMap, null);
if (event) events.push(event);
}
} catch (error) {
Expand All @@ -170,24 +222,18 @@ function findTrackingEvents(ast, filePath, customConfigs = []) {
* @param {Object} node - CallExpression node
* @param {Array<Object>} ancestors - Ancestor nodes
* @param {string} filePath - File path
* @param {Object} constantMap - Constant string map
* @param {Object} [customConfig] - Custom function configuration object
* @returns {Object|null} Extracted event or null
*/
function extractTrackingEvent(node, ancestors, filePath, customConfig) {
// Detect the analytics source
function extractTrackingEvent(node, ancestors, filePath, constantMap, customConfig) {
const source = detectAnalyticsSource(node, customConfig?.functionName);
if (source === 'unknown') {
return null;
}

// Extract event data based on the source
const eventData = extractEventData(node, source, customConfig);

// Get location and context information
const eventData = extractEventData(node, source, constantMap, customConfig);
const line = node.loc.start.line;
const functionName = findWrappingFunction(node, ancestors);

// Process the event data into final format
return processEventData(eventData, source, filePath, line, functionName, customConfig);
}

Expand Down
84 changes: 66 additions & 18 deletions src/analyze/typescript/extractors/event-extractor.js
Original file line number Diff line number Diff line change
Expand Up @@ -296,27 +296,75 @@ function resolvePropertyAccessToString(node, checker, sourceFile) {
try {
// Get the symbol for the property access
const symbol = checker.getSymbolAtLocation(node);
if (!symbol || !symbol.valueDeclaration) {
return null;
}

// Check if it's a property assignment with a string initializer
if (ts.isPropertyAssignment(symbol.valueDeclaration) &&
symbol.valueDeclaration.initializer &&
ts.isStringLiteral(symbol.valueDeclaration.initializer)) {
return symbol.valueDeclaration.initializer.text;
if (symbol && symbol.valueDeclaration) {
// Check if it's a property assignment with a string initializer
if (ts.isPropertyAssignment(symbol.valueDeclaration) &&
symbol.valueDeclaration.initializer &&
ts.isStringLiteral(symbol.valueDeclaration.initializer)) {
return symbol.valueDeclaration.initializer.text;
}

// Check if it's a variable declaration property (string literal type)
if (ts.isPropertySignature(symbol.valueDeclaration) ||
ts.isMethodSignature(symbol.valueDeclaration)) {
const type = checker.getTypeAtLocation(node);
if (type && type.isStringLiteral && type.isStringLiteral()) {
return type.value;
}
}
}

// Check if it's a variable declaration property
if (ts.isPropertySignature(symbol.valueDeclaration) ||
ts.isMethodSignature(symbol.valueDeclaration)) {
// Try to get the type and see if it's a string literal type
const type = checker.getTypeAtLocation(node);
if (type.isStringLiteral && type.isStringLiteral()) {
return type.value;

// ---------------------------------------------------------------------
// Fallback – manually resolve patterns like:
// const CONST = { KEY: 'value' };
// const CONST = Object.freeze({ KEY: 'value' });
// And later used as CONST.KEY
// ---------------------------------------------------------------------
if (ts.isIdentifier(node.expression)) {
const objIdentifier = node.expression;
const initializer = resolveIdentifierToInitializer(checker, objIdentifier, sourceFile);
if (initializer) {
let objectLiteral = null;

// Handle direct object literal initializers
if (ts.isObjectLiteralExpression(initializer)) {
objectLiteral = initializer;
}
// Handle Object.freeze({ ... }) pattern
else if (ts.isCallExpression(initializer)) {
const callee = initializer.expression;
if (
ts.isPropertyAccessExpression(callee) &&
ts.isIdentifier(callee.expression) &&
callee.expression.escapedText === 'Object' &&
callee.name.escapedText === 'freeze' &&
initializer.arguments.length > 0 &&
ts.isObjectLiteralExpression(initializer.arguments[0])
) {
objectLiteral = initializer.arguments[0];
}
}

if (objectLiteral) {
const propNode = findPropertyByKey(objectLiteral, node.name.escapedText || node.name.text);
if (propNode && propNode.initializer && ts.isStringLiteral(propNode.initializer)) {
return propNode.initializer.text;
}
}
}
}


// Final fallback – use type information at location (works for imported Object.freeze constants)
try {
const t = checker.getTypeAtLocation(node);
if (t && t.isStringLiteral && typeof t.isStringLiteral === 'function' && t.isStringLiteral()) {
return t.value;
}
if (t && t.flags && (t.flags & ts.TypeFlags.StringLiteral)) {
return t.value;
}
} catch (_) {/* ignore */}

return null;
} catch (error) {
return null;
Expand Down
Loading