diff --git a/README.md b/README.md index 3251923..69f18ca 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ npx @flisk/analyze-tracking /path/to/project [options] If you have your own in-house tracker or a wrapper function that calls other tracking libraries, you can specify the function signature with the `-c` or `--customFunction` option. +#### Standard Custom Function Format + Your function signature should be in the following format: ```js yourCustomTrackFunctionName(EVENT_NAME, PROPERTIES, customFieldOne, customFieldTwo) @@ -57,11 +59,45 @@ yourCustomTrackFunctionName(userId, EVENT_NAME, PROPERTIES) If your function follows the standard format `yourCustomTrackFunctionName(EVENT_NAME, PROPERTIES)`, you can simply pass in `yourCustomTrackFunctionName` to `--customFunction` as a shorthand. +#### Method-Name-as-Event Format + +For tracking patterns where the method name itself is the event name (e.g., `yourClass.yourEventName({...})`), use the special `EVENT_NAME` placeholder in the method position: + +```js +yourClass.EVENT_NAME(PROPERTIES) +``` + +This pattern tells the analyzer that: +- `yourClass` is the object name to match +- The method name after the dot (e.g., `viewItemList`, `addToCart`) is the event name +- `PROPERTIES` is the properties object (defaults to the first argument if not specified) + +**Example:** +```typescript +// Code in your project: +yourClass.viewItemList({ items: [...] }); +yourClass.addToCart({ item: {...}, value: 100 }); +yourClass.purchase({ userId: '123', value: 100 }); + +// Command: +npx @flisk/analyze-tracking /path/to/project --customFunction "yourClass.EVENT_NAME(PROPERTIES)" +``` + +This will detect: +- Event: `viewItemList` with properties from the first argument +- Event: `addToCart` with properties from the first argument +- Event: `purchase` with properties from the first argument + +_**Note:** This pattern is currently only supported for JavaScript and TypeScript code._ + +#### Multiple Custom Functions + You can also pass in multiple custom function signatures by passing in the `--customFunction` option multiple times or by passing in a space-separated list of function signatures. ```sh npx @flisk/analyze-tracking /path/to/project --customFunction "yourFunc1" --customFunction "yourFunc2(userId, EVENT_NAME, PROPERTIES)" npx @flisk/analyze-tracking /path/to/project -c "yourFunc1" "yourFunc2(userId, EVENT_NAME, PROPERTIES)" +npx @flisk/analyze-tracking /path/to/project -c "yourClass.EVENT_NAME(PROPERTIES)" "customTrack(EVENT_NAME, PROPERTIES)" ``` diff --git a/schema.json b/schema.json index 052e12d..2d95d89 100644 --- a/schema.json +++ b/schema.json @@ -142,6 +142,16 @@ "items": { "$ref": "#/definitions/property", "description": "Schema for array items when type is 'array'" + }, + "values": { + "type": "array", + "items": { + "oneOf": [ + { "type": "string" }, + { "type": "number" } + ] + }, + "description": "Possible values when type is 'enum'" } }, "required": [ diff --git a/src/analyze/javascript/detectors/analytics-source.js b/src/analyze/javascript/detectors/analytics-source.js index c3678f0..df373f7 100644 --- a/src/analyze/javascript/detectors/analytics-source.js +++ b/src/analyze/javascript/detectors/analytics-source.js @@ -8,16 +8,25 @@ const { ANALYTICS_PROVIDERS, NODE_TYPES } = require('../constants'); /** * Detects the analytics provider from a CallExpression node * @param {Object} node - AST CallExpression node - * @param {string} [customFunction] - Custom function name to detect + * @param {string|Object} [customFunctionOrConfig] - Custom function name string or custom config object * @returns {string} The detected analytics source or 'unknown' */ -function detectAnalyticsSource(node, customFunction) { +function detectAnalyticsSource(node, customFunctionOrConfig) { if (!node.callee) { return 'unknown'; } // Check for custom function first - if (customFunction && isCustomFunction(node, customFunction)) { + // Support both old string format and new config object format + const customConfig = typeof customFunctionOrConfig === 'object' ? customFunctionOrConfig : null; + const customFunction = typeof customFunctionOrConfig === 'string' ? customFunctionOrConfig : (customConfig?.functionName); + + if (customConfig?.isMethodAsEvent) { + // Method-as-event pattern: match any method on the specified object + if (isMethodAsEventFunction(node, customConfig)) { + return 'custom'; + } + } else if (customFunction && isCustomFunction(node, customFunction)) { return 'custom'; } @@ -36,6 +45,31 @@ function detectAnalyticsSource(node, customFunction) { return 'unknown'; } +/** + * Checks if the node matches a method-as-event custom function pattern + * @param {Object} node - AST CallExpression node + * @param {Object} customConfig - Custom function configuration with isMethodAsEvent: true + * @returns {boolean} + */ +function isMethodAsEventFunction(node, customConfig) { + if (!customConfig?.isMethodAsEvent || !customConfig?.objectName) { + return false; + } + + // Must be a MemberExpression: objectName.methodName(...) + if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) { + return false; + } + + // The object part must match the configured objectName + const objectNode = node.callee.object; + if (objectNode.type !== NODE_TYPES.IDENTIFIER) { + return false; + } + + return objectNode.name === customConfig.objectName; +} + /** * Checks if the node is a custom function call * @param {Object} node - AST CallExpression node @@ -122,7 +156,7 @@ function detectFunctionBasedProvider(node) { } const functionName = node.callee.name; - + for (const provider of Object.values(ANALYTICS_PROVIDERS)) { if (provider.type === 'function' && provider.functionName === functionName) { return provider.name; diff --git a/src/analyze/javascript/extractors/event-extractor.js b/src/analyze/javascript/extractors/event-extractor.js index f9bff58..f5ce300 100644 --- a/src/analyze/javascript/extractors/event-extractor.js +++ b/src/analyze/javascript/extractors/event-extractor.js @@ -72,15 +72,15 @@ function extractSnowplowEvent(node, constantMap) { // tracker.track(buildStructEvent({ action: 'event_name', ... })) const firstArg = node.arguments[0]; - - if (firstArg.type === NODE_TYPES.CALL_EXPRESSION && + + if (firstArg.type === NODE_TYPES.CALL_EXPRESSION && firstArg.arguments.length > 0) { const structEventArg = firstArg.arguments[0]; - + if (structEventArg.type === NODE_TYPES.OBJECT_EXPRESSION) { const actionProperty = findPropertyByKey(structEventArg, 'action'); const eventName = actionProperty ? getStringValue(actionProperty.value, constantMap) : null; - + return { eventName, propertiesNode: structEventArg }; } } @@ -119,7 +119,7 @@ function extractGTMEvent(node, constantMap) { // dataLayer.push({ event: 'event_name', property1: 'value1', property2: 'value2' }) const firstArg = node.arguments[0]; - + if (firstArg.type !== NODE_TYPES.OBJECT_EXPRESSION) { return { eventName: null, propertiesNode: null }; } @@ -131,11 +131,11 @@ function extractGTMEvent(node, constantMap) { } const eventName = getStringValue(eventProperty.value, constantMap); - + // Create a modified properties node without the 'event' property const modifiedPropertiesNode = { ...firstArg, - properties: firstArg.properties.filter(prop => + properties: firstArg.properties.filter(prop => prop.key && (prop.key.name !== 'event' && prop.key.value !== 'event') ) }; @@ -171,10 +171,27 @@ function extractDefaultEvent(node, constantMap) { function extractCustomEvent(node, constantMap, customConfig) { const args = node.arguments || []; - const eventArg = args[customConfig?.eventIndex ?? 0]; - const propertiesArg = args[customConfig?.propertiesIndex ?? 1]; + let eventName; + let propertiesArg; + + if (customConfig?.isMethodAsEvent) { + // Method-as-event pattern: event name comes from the method name + if (node.callee.type === NODE_TYPES.MEMBER_EXPRESSION && + node.callee.property.type === NODE_TYPES.IDENTIFIER) { + eventName = node.callee.property.name; + } else { + // Fallback: could not extract method name + eventName = null; + } - const eventName = getStringValue(eventArg, constantMap); + // Properties are at the configured index (default 0) + propertiesArg = args[customConfig?.propertiesIndex ?? 0]; + } else { + // Standard custom function pattern: event name comes from argument + const eventArg = args[customConfig?.eventIndex ?? 0]; + propertiesArg = args[customConfig?.propertiesIndex ?? 1]; + eventName = getStringValue(eventArg, constantMap); + } const extraArgs = {}; if (customConfig && customConfig.extraParams) { @@ -274,8 +291,8 @@ function getStringValue(node, constantMap = {}) { */ function findPropertyByKey(objectNode, key) { if (!objectNode.properties) return null; - - return objectNode.properties.find(prop => + + return objectNode.properties.find(prop => prop.key && (prop.key.name === key || prop.key.value === key) ); } diff --git a/src/analyze/javascript/parser.js b/src/analyze/javascript/parser.js index 65aa8f4..20e08d5 100644 --- a/src/analyze/javascript/parser.js +++ b/src/analyze/javascript/parser.js @@ -53,7 +53,7 @@ class ParseError extends Error { */ function parseFile(filePath) { let code; - + try { code = fs.readFileSync(filePath, 'utf8'); } catch (error) { @@ -72,16 +72,33 @@ function parseFile(filePath) { // --------------------------------------------- /** - * Determines whether a CallExpression node matches the provided custom function name. - * Supports both simple identifiers (e.g. myTrack) and dot-separated members (e.g. Custom.track). + * Determines whether a CallExpression node matches the provided custom function configuration. + * Supports both simple identifiers (e.g. myTrack), dot-separated members (e.g. Custom.track), + * and method-as-event patterns (e.g. eventCalls.EVENT_NAME). * The logic mirrors isCustomFunction from detectors/analytics-source.js but is kept local to avoid * circular dependencies. * @param {Object} node – CallExpression AST node - * @param {string} fnName – Custom function name (could include dots) + * @param {Object} customConfig – Custom function configuration object * @returns {boolean} */ -function nodeMatchesCustomFunction(node, fnName) { - if (!fnName || !node.callee) return false; +function nodeMatchesCustomFunction(node, customConfig) { + if (!customConfig || !node.callee) return false; + + // Handle method-as-event pattern + if (customConfig.isMethodAsEvent && customConfig.objectName) { + if (node.callee.type !== NODE_TYPES.MEMBER_EXPRESSION) { + return false; + } + const objectNode = node.callee.object; + if (objectNode.type !== NODE_TYPES.IDENTIFIER) { + return false; + } + return objectNode.name === customConfig.objectName; + } + + // Handle standard custom function patterns + const fnName = customConfig.functionName; + if (!fnName) return false; // Support chained calls in function name by stripping trailing parens from each segment const parts = fnName.split('.').map(p => p.replace(/\(\s*\)$/, '')); @@ -204,7 +221,7 @@ function findTrackingEvents(ast, filePath, customConfigs = []) { // Attempt to match any custom function first to avoid mis-classifying built-in providers if (Array.isArray(customConfigs) && customConfigs.length > 0) { for (const cfg of customConfigs) { - if (cfg && nodeMatchesCustomFunction(node, cfg.functionName)) { + if (cfg && nodeMatchesCustomFunction(node, cfg)) { matchedCustomConfig = cfg; break; } @@ -237,7 +254,8 @@ function findTrackingEvents(ast, filePath, customConfigs = []) { * @returns {Object|null} Extracted event or null */ function extractTrackingEvent(node, ancestors, filePath, constantMap, customConfig) { - const source = detectAnalyticsSource(node, customConfig?.functionName); + // Pass the full customConfig object (not just functionName) to support method-as-event patterns + const source = detectAnalyticsSource(node, customConfig || null); if (source === 'unknown') { return null; } diff --git a/src/analyze/typescript/detectors/analytics-source.js b/src/analyze/typescript/detectors/analytics-source.js index 0726ea5..0aa3bda 100644 --- a/src/analyze/typescript/detectors/analytics-source.js +++ b/src/analyze/typescript/detectors/analytics-source.js @@ -9,16 +9,25 @@ const { ANALYTICS_PROVIDERS } = require('../constants'); /** * Detects the analytics provider from a CallExpression node * @param {Object} node - TypeScript CallExpression node - * @param {string} [customFunction] - Custom function name to detect + * @param {string|Object} [customFunctionOrConfig] - Custom function name string or custom config object * @returns {string} The detected analytics source or 'unknown' */ -function detectAnalyticsSource(node, customFunction) { +function detectAnalyticsSource(node, customFunctionOrConfig) { if (!node.expression) { return 'unknown'; } // Check for custom function first - if (customFunction && isCustomFunction(node, customFunction)) { + // Support both old string format and new config object format + const customConfig = typeof customFunctionOrConfig === 'object' ? customFunctionOrConfig : null; + const customFunction = typeof customFunctionOrConfig === 'string' ? customFunctionOrConfig : (customConfig?.functionName); + + if (customConfig?.isMethodAsEvent) { + // Method-as-event pattern: match any method on the specified object + if (isMethodAsEventFunction(node, customConfig)) { + return 'custom'; + } + } else if (customFunction && isCustomFunction(node, customFunction)) { return 'custom'; } @@ -37,6 +46,31 @@ function detectAnalyticsSource(node, customFunction) { return 'unknown'; } +/** + * Checks if the node matches a method-as-event custom function pattern + * @param {Object} node - TypeScript CallExpression node + * @param {Object} customConfig - Custom function configuration with isMethodAsEvent: true + * @returns {boolean} + */ +function isMethodAsEventFunction(node, customConfig) { + if (!customConfig?.isMethodAsEvent || !customConfig?.objectName) { + return false; + } + + // Must be a PropertyAccessExpression: objectName.methodName(...) + if (!ts.isPropertyAccessExpression(node.expression)) { + return false; + } + + // The object part must match the configured objectName + const objectExpr = node.expression.expression; + if (!ts.isIdentifier(objectExpr)) { + return false; + } + + return objectExpr.escapedText === customConfig.objectName; +} + /** * Checks if the node is a custom function call * @param {Object} node - TypeScript CallExpression node diff --git a/src/analyze/typescript/extractors/event-extractor.js b/src/analyze/typescript/extractors/event-extractor.js index 9b6789f..94108f7 100644 --- a/src/analyze/typescript/extractors/event-extractor.js +++ b/src/analyze/typescript/extractors/event-extractor.js @@ -76,10 +76,10 @@ function extractSnowplowEvent(node, checker, sourceFile) { // tracker.track(buildStructEvent({ action: 'event_name', ... })) const firstArg = node.arguments[0]; - + // Check if it's a direct buildStructEvent call - if (ts.isCallExpression(firstArg) && - ts.isIdentifier(firstArg.expression) && + if (ts.isCallExpression(firstArg) && + ts.isIdentifier(firstArg.expression) && firstArg.expression.escapedText === 'buildStructEvent' && firstArg.arguments.length > 0) { const structEventArg = firstArg.arguments[0]; @@ -141,7 +141,7 @@ function extractGTMEvent(node, checker, sourceFile) { // dataLayer.push({ event: 'event_name', property1: 'value1', property2: 'value2' }) const firstArg = node.arguments[0]; - + if (!ts.isObjectLiteralExpression(firstArg)) { return { eventName: null, propertiesNode: null }; } @@ -153,7 +153,7 @@ function extractGTMEvent(node, checker, sourceFile) { } const eventName = getStringValue(eventProperty.initializer, checker, sourceFile); - + // Create a modified properties node without the 'event' property const modifiedProperties = firstArg.properties.filter(prop => { if (ts.isPropertyAssignment(prop) && prop.name) { @@ -169,7 +169,7 @@ function extractGTMEvent(node, checker, sourceFile) { // Create a synthetic object literal with the filtered properties const modifiedPropertiesNode = ts.factory.createObjectLiteralExpression(modifiedProperties); - + // Copy source positions for proper analysis if (firstArg.pos !== undefined) { modifiedPropertiesNode.pos = firstArg.pos; @@ -192,10 +192,31 @@ function extractGTMEvent(node, checker, sourceFile) { function extractCustomEvent(node, checker, sourceFile, customConfig) { const args = node.arguments || []; - const eventArg = args[customConfig?.eventIndex ?? 0]; - const propertiesArg = args[customConfig?.propertiesIndex ?? 1]; + let eventName; + let propertiesArg; + + if (customConfig?.isMethodAsEvent) { + // Method-as-event pattern: event name comes from the method name + if (ts.isPropertyAccessExpression(node.expression)) { + const methodName = node.expression.name; + if (methodName && ts.isIdentifier(methodName)) { + eventName = methodName.escapedText || methodName.text; + } else { + // Fallback: could not extract method name + eventName = null; + } + } else { + eventName = null; + } - const eventName = getStringValue(eventArg, checker, sourceFile); + // Properties are at the configured index (default 0) + propertiesArg = args[customConfig?.propertiesIndex ?? 0]; + } else { + // Standard custom function pattern: event name comes from argument + const eventArg = args[customConfig?.eventIndex ?? 0]; + propertiesArg = args[customConfig?.propertiesIndex ?? 1]; + eventName = getStringValue(eventArg, checker, sourceFile); + } const extraArgs = {}; if (customConfig && customConfig.extraParams) { @@ -320,22 +341,22 @@ function processEventData(eventData, source, filePath, line, functionName, check */ function getStringValue(node, checker, sourceFile) { if (!node) return null; - + // Handle string literals (existing behavior) if (ts.isStringLiteral(node)) { return node.text; } - + // Handle property access expressions like TRACKING_EVENTS.ECOMMERCE_PURCHASE if (ts.isPropertyAccessExpression(node)) { return resolvePropertyAccessToString(node, checker, sourceFile); } - + // Handle identifiers that might reference constants if (ts.isIdentifier(node)) { return resolveIdentifierToString(node, checker, sourceFile); } - + return null; } @@ -438,39 +459,39 @@ function resolveIdentifierToString(node, checker, sourceFile) { if (!symbol) { return null; } - + // First try to resolve through value declaration if (symbol.valueDeclaration) { const declaration = symbol.valueDeclaration; - + // Handle variable declarations with string literal initializers - if (ts.isVariableDeclaration(declaration) && + if (ts.isVariableDeclaration(declaration) && declaration.initializer && ts.isStringLiteral(declaration.initializer)) { return declaration.initializer.text; } - + // Handle const declarations with object literals containing string properties - if (ts.isVariableDeclaration(declaration) && + if (ts.isVariableDeclaration(declaration) && declaration.initializer && ts.isObjectLiteralExpression(declaration.initializer)) { // This case is handled by property access resolution return null; } } - + // If value declaration doesn't exist or doesn't help, try type resolution // This handles imported constants that are resolved through TypeScript's type system const type = checker.getTypeOfSymbolAtLocation(symbol, node); if (type && type.isStringLiteral && typeof type.isStringLiteral === 'function' && type.isStringLiteral()) { return type.value; } - + // Alternative approach for string literal types (different TypeScript versions) if (type && type.flags && (type.flags & ts.TypeFlags.StringLiteral)) { return type.value; } - + return null; } catch (error) { return null; @@ -485,7 +506,7 @@ function resolveIdentifierToString(node, checker, sourceFile) { */ function findPropertyByKey(objectNode, key) { if (!objectNode.properties) return null; - + return objectNode.properties.find(prop => { if (prop.name) { if (ts.isIdentifier(prop.name)) { @@ -506,30 +527,37 @@ function findPropertyByKey(objectNode, key) { */ function cleanupProperties(properties) { const cleaned = {}; - + for (const [key, value] of Object.entries(properties)) { if (value && typeof value === 'object') { - // Remove __unresolved marker + // Remove __unresolved marker from the value itself if (value.__unresolved) { delete value.__unresolved; } - + // Recursively clean nested properties if (value.properties) { value.properties = cleanupProperties(value.properties); } - - // Clean array item properties - if (value.type === 'array' && value.items && value.items.properties) { - value.items.properties = cleanupProperties(value.items.properties); + + // Clean array item properties and __unresolved markers + if (value.type === 'array' && value.items) { + // Remove __unresolved from items directly + if (value.items.__unresolved) { + delete value.items.__unresolved; + } + // Clean nested properties in items + if (value.items.properties) { + value.items.properties = cleanupProperties(value.items.properties); + } } - + cleaned[key] = value; } else { cleaned[key] = value; } } - + return cleaned; } diff --git a/src/analyze/typescript/extractors/property-extractor.js b/src/analyze/typescript/extractors/property-extractor.js index 66ec3a3..4b91e1f 100644 --- a/src/analyze/typescript/extractors/property-extractor.js +++ b/src/analyze/typescript/extractors/property-extractor.js @@ -4,11 +4,15 @@ */ const ts = require('typescript'); -const { - getTypeOfNode, - resolveTypeToProperties, +const { + getTypeOfNode, + resolveTypeToProperties, getBasicTypeOfArrayElement, - isCustomType + isCustomType, + isEnumType, + getEnumValues, + resolveTypeObjectToSchema, + extractTypeProperties } = require('../utils/type-resolver'); /** @@ -40,7 +44,7 @@ function extractProperties(checker, node) { Object.assign(properties, spreadProperties); continue; } - + const key = getPropertyKey(prop); if (!key) continue; @@ -66,16 +70,16 @@ function getPropertyKey(prop) { } return null; } - + // Regular property with name if (ts.isIdentifier(prop.name)) { return prop.name.escapedText; } - + if (ts.isStringLiteral(prop.name)) { return prop.name.text; } - + return null; } @@ -90,25 +94,25 @@ function extractPropertySchema(checker, prop) { if (ts.isShorthandPropertyAssignment(prop)) { return extractShorthandPropertySchema(checker, prop); } - + // Handle property assignments with initializers if (ts.isPropertyAssignment(prop)) { if (prop.initializer) { return extractValueSchema(checker, prop.initializer); } - + // Property with type annotation but no initializer if (prop.type) { const typeString = checker.typeToString(checker.getTypeFromTypeNode(prop.type)); return resolveTypeSchema(checker, typeString); } } - + // Handle method declarations if (ts.isMethodDeclaration(prop)) { return { type: 'function' }; } - + return null; } @@ -155,30 +159,11 @@ function extractShorthandPropertySchema(checker, prop) { return { type: 'any' }; } } - + const propType = checker.getTypeAtLocation(prop.name); - const typeString = checker.typeToString(propType); - - // Handle array types - if (isArrayType(typeString)) { - return extractArrayTypeSchema(checker, propType, typeString); - } - - // Handle other types - const resolvedType = resolveTypeToProperties(checker, typeString); - - // If it's an unresolved custom type, try to extract interface properties - if (resolvedType.__unresolved) { - const interfaceProps = extractInterfaceProperties(checker, propType); - if (Object.keys(interfaceProps).length > 0) { - return { - type: 'object', - properties: interfaceProps - }; - } - } - - return resolvedType; + + // Use the type object resolver for better accuracy + return resolveTypeObjectToSchema(checker, propType); } /** @@ -188,33 +173,29 @@ function extractShorthandPropertySchema(checker, prop) { * @returns {PropertySchema} */ function extractValueSchema(checker, valueNode) { - // Object literal + // Object literal - extract inline properties if (ts.isObjectLiteralExpression(valueNode)) { return { type: 'object', properties: extractProperties(checker, valueNode) }; } - + // Array literal if (ts.isArrayLiteralExpression(valueNode)) { return extractArrayLiteralSchema(checker, valueNode); } - - // Identifier (variable reference) - if (ts.isIdentifier(valueNode)) { - return extractIdentifierSchema(checker, valueNode); - } - + // Literal values const literalType = getLiteralType(valueNode); if (literalType) { return { type: literalType }; } - - // For other expressions, get the type from TypeChecker - const typeString = getTypeOfNode(checker, valueNode); - return resolveTypeSchema(checker, typeString); + + // For all other expressions (identifiers, property access, etc.), + // use the type object resolver for accurate type resolution + const valueType = checker.getTypeAtLocation(valueNode); + return resolveTypeObjectToSchema(checker, valueType); } /** @@ -230,17 +211,17 @@ function extractArrayLiteralSchema(checker, node) { items: { type: 'any' } }; } - + // Check types of all elements const elementTypes = new Set(); for (const element of node.elements) { const elemType = getBasicTypeOfArrayElement(checker, element); elementTypes.add(elemType); } - + // If all elements are the same type, use that type const itemType = elementTypes.size === 1 ? Array.from(elementTypes)[0] : 'any'; - + return { type: 'array', items: { type: itemType } @@ -255,28 +236,9 @@ function extractArrayLiteralSchema(checker, node) { */ function extractIdentifierSchema(checker, identifier) { const identifierType = checker.getTypeAtLocation(identifier); - const typeString = checker.typeToString(identifierType); - - // Handle array types - if (isArrayType(typeString)) { - return extractArrayTypeSchema(checker, identifierType, typeString); - } - - // Handle other types - const resolvedType = resolveTypeToProperties(checker, typeString); - - // If it's an unresolved custom type, try to extract interface properties - if (resolvedType.__unresolved) { - const interfaceProps = extractInterfaceProperties(checker, identifierType); - if (Object.keys(interfaceProps).length > 0) { - return { - type: 'object', - properties: interfaceProps - }; - } - } - - return resolvedType; + + // Use the new type object resolver for better accuracy + return resolveTypeObjectToSchema(checker, identifierType); } /** @@ -288,7 +250,7 @@ function extractIdentifierSchema(checker, identifier) { */ function extractArrayTypeSchema(checker, type, typeString) { let elementType = null; - + // Try to get type arguments for generic types if (type.target && type.typeArguments && type.typeArguments.length > 0) { elementType = type.typeArguments[0]; @@ -302,7 +264,7 @@ function extractArrayTypeSchema(checker, type, typeString) { // Indexed access failed } } - + if (elementType) { const elementInterfaceProps = extractInterfaceProperties(checker, elementType); if (Object.keys(elementInterfaceProps).length > 0) { @@ -327,7 +289,7 @@ function extractArrayTypeSchema(checker, type, typeString) { }; } } - + return { type: 'array', items: { type: 'any' } @@ -342,12 +304,12 @@ function extractArrayTypeSchema(checker, type, typeString) { */ function resolveTypeSchema(checker, typeString) { const resolvedType = resolveTypeToProperties(checker, typeString); - + // Clean up any unresolved markers for simple types if (resolvedType.__unresolved) { delete resolvedType.__unresolved; } - + return resolvedType; } @@ -372,8 +334,8 @@ function getLiteralType(node) { * @returns {boolean} */ function isArrayType(typeString) { - return typeString.includes('[]') || - typeString.startsWith('Array<') || + return typeString.includes('[]') || + typeString.startsWith('Array<') || typeString.startsWith('ReadonlyArray<') || typeString.startsWith('readonly '); } @@ -388,13 +350,13 @@ function extractSpreadProperties(checker, spreadNode) { if (!spreadNode.expression) { return {}; } - + // If the spread is an identifier, resolve it to its declaration if (ts.isIdentifier(spreadNode.expression)) { const symbol = checker.getSymbolAtLocation(spreadNode.expression); if (symbol && symbol.declarations && symbol.declarations.length > 0) { const declaration = symbol.declarations[0]; - + // If it's a variable declaration with an object literal initializer if (ts.isVariableDeclaration(declaration) && declaration.initializer) { if (ts.isObjectLiteralExpression(declaration.initializer)) { @@ -403,17 +365,17 @@ function extractSpreadProperties(checker, spreadNode) { } } } - + // Fallback to the original identifier schema extraction const identifierSchema = extractIdentifierSchema(checker, spreadNode.expression); return identifierSchema.properties || {}; } - + // If the spread is an object literal, extract its properties if (ts.isObjectLiteralExpression(spreadNode.expression)) { return extractProperties(checker, spreadNode.expression); } - + // For other expressions, try to get the type and extract properties from it try { const spreadType = checker.getTypeAtLocation(spreadNode.expression); @@ -432,20 +394,65 @@ function extractSpreadProperties(checker, spreadNode) { function extractInterfaceProperties(checker, type) { const properties = {}; const typeSymbol = type.getSymbol(); - + if (!typeSymbol) return properties; - + + // Check if this is an enum type - don't expand enum string methods + const typeString = checker.typeToString(type); + if (isEnumType(checker, typeString)) { + // Return empty - the caller should handle enum types specially + return properties; + } + + // Check if this looks like a string primitive with methods - skip it + if (isStringPrototype(type, checker)) { + return properties; + } + // Get all properties of the type const members = checker.getPropertiesOfType(type); - - for (const member of members) { + + // Filter out built-in methods (string prototype methods, etc.) + const userDefinedMembers = members.filter(member => { + const name = member.name; + // Skip common built-in method names + if (STRING_PROTOTYPE_METHODS.has(name)) { + return false; + } + // Skip symbols + if (name.startsWith('__@')) { + return false; + } + return true; + }); + + for (const member of userDefinedMembers) { try { const memberType = checker.getTypeOfSymbolAtLocation(member, member.valueDeclaration); const memberTypeString = checker.typeToString(memberType); - + + // Skip function types + if (memberTypeString.includes('=>') || memberTypeString.startsWith('(')) { + continue; + } + + // Check if member type is an enum + if (isEnumType(checker, memberTypeString, memberType)) { + const enumValues = getEnumValues(checker, memberTypeString, memberType); + if (enumValues && enumValues.length > 0) { + properties[member.name] = { + type: 'enum', + values: enumValues + }; + } else { + properties[member.name] = { type: 'string' }; + } + continue; + } + // Recursively resolve the member type const resolvedType = resolveTypeToProperties(checker, memberTypeString); - + // If it's an unresolved object type, try to extract its properties if (resolvedType.__unresolved) { const nestedProperties = extractInterfaceProperties(checker, memberType); @@ -470,10 +477,41 @@ function extractInterfaceProperties(checker, type) { properties[member.name] = { type: 'any' }; } } - + return properties; } +/** + * Set of common string prototype method names to filter out + */ +const STRING_PROTOTYPE_METHODS = new Set([ + 'toString', 'charAt', 'charCodeAt', 'concat', 'indexOf', 'lastIndexOf', + 'localeCompare', 'match', 'replace', 'search', 'slice', 'split', + 'substring', 'toLowerCase', 'toLocaleLowerCase', 'toUpperCase', + 'toLocaleUpperCase', 'trim', 'length', 'substr', 'valueOf', + 'codePointAt', 'includes', 'endsWith', 'normalize', 'repeat', + 'startsWith', 'anchor', 'big', 'blink', 'bold', 'fixed', + 'fontcolor', 'fontsize', 'italics', 'link', 'small', 'strike', + 'sub', 'sup', 'padStart', 'padEnd', 'trimEnd', 'trimStart', + 'trimLeft', 'trimRight', 'matchAll', 'replaceAll', 'at', + 'isWellFormed', 'toWellFormed' +]); + +/** + * Checks if a type is a string primitive that would have prototype methods + * @param {Object} type - TypeScript Type object + * @param {Object} checker - TypeScript type checker + * @returns {boolean} + */ +function isStringPrototype(type, checker) { + if (!type) return false; + const members = checker.getPropertiesOfType(type); + // If the type has common string methods, it's likely a string + const stringMethodCount = members.filter(m => STRING_PROTOTYPE_METHODS.has(m.name)).length; + // If more than half of the members are string methods, treat as string + return stringMethodCount > 10 && stringMethodCount > members.length / 2; +} + module.exports = { extractProperties, extractInterfaceProperties diff --git a/src/analyze/typescript/parser.js b/src/analyze/typescript/parser.js index ceef6ec..efd13d5 100644 --- a/src/analyze/typescript/parser.js +++ b/src/analyze/typescript/parser.js @@ -168,16 +168,34 @@ function findTrackingEvents(sourceFile, checker, filePath, customConfigs = []) { const events = []; /** - * Tests if a CallExpression matches a custom function name + * Tests if a CallExpression matches a custom function configuration * @param {Object} callNode - The call expression node - * @param {string} functionName - Function name to match + * @param {Object} customConfig - Custom function configuration object * @returns {boolean} True if matches */ - function matchesCustomFunction(callNode, functionName) { - if (!functionName || !callNode.expression) { + function matchesCustomFunction(callNode, customConfig) { + if (!customConfig || !callNode.expression) { return false; } - + + // Handle method-as-event pattern + if (customConfig.isMethodAsEvent && customConfig.objectName) { + if (!ts.isPropertyAccessExpression(callNode.expression)) { + return false; + } + const objectExpr = callNode.expression.expression; + if (!ts.isIdentifier(objectExpr)) { + return false; + } + return objectExpr.escapedText === customConfig.objectName; + } + + // Handle standard custom function pattern + const functionName = customConfig.functionName; + if (!functionName) { + return false; + } + try { return callNode.expression.getText() === functionName; } catch { @@ -197,7 +215,7 @@ function findTrackingEvents(sourceFile, checker, filePath, customConfigs = []) { // Check for custom function matches if (Array.isArray(customConfigs) && customConfigs.length > 0) { for (const config of customConfigs) { - if (config && matchesCustomFunction(node, config.functionName)) { + if (config && matchesCustomFunction(node, config)) { matchedCustomConfig = config; break; } @@ -211,7 +229,7 @@ function findTrackingEvents(sourceFile, checker, filePath, customConfigs = []) { filePath, matchedCustomConfig ); - + if (event) { events.push(event); } @@ -238,7 +256,8 @@ function findTrackingEvents(sourceFile, checker, filePath, customConfigs = []) { */ function extractTrackingEvent(node, sourceFile, checker, filePath, customConfig) { // Detect the analytics source - const source = detectAnalyticsSource(node, customConfig?.functionName); + // Pass the full customConfig object (not just functionName) to support method-as-event patterns + const source = detectAnalyticsSource(node, customConfig || null); if (source === 'unknown') { return null; } diff --git a/src/analyze/typescript/utils/type-resolver.js b/src/analyze/typescript/utils/type-resolver.js index 3270ab6..c6086de 100644 --- a/src/analyze/typescript/utils/type-resolver.js +++ b/src/analyze/typescript/utils/type-resolver.js @@ -18,24 +18,24 @@ function resolveIdentifierToInitializer(checker, identifier, sourceFile) { if (!symbol || !symbol.valueDeclaration) { return null; } - + const declaration = symbol.valueDeclaration; - + // Handle variable declarations if (ts.isVariableDeclaration(declaration) && declaration.initializer) { return declaration.initializer; } - + // Handle property assignments if (ts.isPropertyAssignment(declaration) && declaration.initializer) { return declaration.initializer; } - + // Handle parameter with default value if (ts.isParameter(declaration) && declaration.initializer) { return declaration.initializer; } - + return null; } catch (error) { return null; @@ -69,12 +69,12 @@ function resolveTypeToProperties(checker, typeString, visitedTypes = new Set()) if (visitedTypes.has(typeString)) { return { type: 'object' }; } - + // Handle primitive types if (['string', 'number', 'boolean', 'any', 'unknown', 'null', 'undefined', 'void', 'never'].includes(typeString)) { return { type: typeString }; } - + // Handle array types: T[] or Array const arrayMatch = typeString.match(/^(.+)\[\]$/) || typeString.match(/^Array<(.+)>$/); if (arrayMatch) { @@ -86,7 +86,7 @@ function resolveTypeToProperties(checker, typeString, visitedTypes = new Set()) items: elementProps }; } - + // Handle readonly array types: readonly T[] or ReadonlyArray const readonlyArrayMatch = typeString.match(/^readonly (.+)\[\]$/) || typeString.match(/^ReadonlyArray<(.+)>$/); if (readonlyArrayMatch) { @@ -98,18 +98,37 @@ function resolveTypeToProperties(checker, typeString, visitedTypes = new Set()) items: elementProps }; } - - // Handle union types - preserve them as-is + + // Handle union types if (typeString.includes('|')) { + // Try to extract the non-undefined/non-null type from the union + const resolvedUnion = resolveUnionType(checker, typeString, visitedTypes); + if (resolvedUnion) { + return resolvedUnion; + } + // Fallback: preserve as-is return { type: typeString }; } - + // Handle intersection types if (typeString.includes('&')) { // For simplicity, mark intersection types as 'object' return { type: 'object' }; } - + + // Check if it's an enum type - don't try to expand enum members + if (checker && isEnumType(checker, typeString)) { + const enumValues = getEnumValues(checker, typeString); + if (enumValues && enumValues.length > 0) { + return { + type: 'enum', + values: enumValues + }; + } + // Fallback for string enums + return { type: 'string' }; + } + // Check if it looks like a custom type/interface if (isCustomType(typeString)) { return { @@ -117,11 +136,245 @@ function resolveTypeToProperties(checker, typeString, visitedTypes = new Set()) __unresolved: typeString }; } - + // Default case - preserve the type string as-is return { type: typeString }; } +/** + * Resolves a union type by extracting the meaningful type (ignoring undefined/null) + * @param {Object} checker - TypeScript type checker + * @param {string} typeString - Union type string + * @param {Set} visitedTypes - Set of visited types + * @returns {Object|null} Resolved type or null + */ +function resolveUnionType(checker, typeString, visitedTypes) { + // Split by | and trim each part + const parts = splitUnionType(typeString); + + // Filter out undefined and null + const meaningfulParts = parts.filter(p => + p !== 'undefined' && p !== 'null' && p.trim() !== '' + ); + + if (meaningfulParts.length === 0) { + return { type: 'null' }; + } + + if (meaningfulParts.length === 1) { + const part = meaningfulParts[0].trim(); + + // Check if it's an object literal type like { id: string; name: string } + if (part.startsWith('{') && part.endsWith('}')) { + const properties = parseObjectLiteralType(part); + if (Object.keys(properties).length > 0) { + return { + type: 'object', + properties + }; + } + } + + // Recursively resolve the meaningful part + return resolveTypeToProperties(checker, part, visitedTypes); + } + + // Multiple meaningful parts - try to find the most specific one + // Prefer object types over primitives + for (const part of meaningfulParts) { + const trimmed = part.trim(); + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + const properties = parseObjectLiteralType(trimmed); + if (Object.keys(properties).length > 0) { + return { + type: 'object', + properties + }; + } + } + } + + // Return null to indicate we couldn't resolve it + return null; +} + +/** + * Splits a union type string into its constituent parts, handling nested braces + * @param {string} typeString - Union type string + * @returns {string[]} Array of type parts + */ +function splitUnionType(typeString) { + const parts = []; + let current = ''; + let depth = 0; + let parenDepth = 0; + let angleDepth = 0; + + for (let i = 0; i < typeString.length; i++) { + const char = typeString[i]; + + if (char === '{') depth++; + else if (char === '}') depth--; + else if (char === '(') parenDepth++; + else if (char === ')') parenDepth--; + else if (char === '<') angleDepth++; + else if (char === '>') angleDepth--; + + if (char === '|' && depth === 0 && parenDepth === 0 && angleDepth === 0) { + parts.push(current.trim()); + current = ''; + } else { + current += char; + } + } + + if (current.trim()) { + parts.push(current.trim()); + } + + return parts; +} + +/** + * Parses an object literal type string like "{ id: string; name: string }" + * @param {string} typeString - Object literal type string + * @returns {Object} Parsed properties + */ +function parseObjectLiteralType(typeString) { + const properties = {}; + + // Remove outer braces and trim + let inner = typeString.slice(1, -1).trim(); + if (!inner) return properties; + + // Split by semicolons (property separators), handling nested braces + const propStrings = splitBySemicolon(inner); + + for (const propString of propStrings) { + const trimmed = propString.trim(); + if (!trimmed) continue; + + // Parse "key: type" or "key?: type" + const colonIndex = findPropertyColonIndex(trimmed); + if (colonIndex === -1) continue; + + let key = trimmed.slice(0, colonIndex).trim(); + const typeStr = trimmed.slice(colonIndex + 1).trim(); + + // Handle optional properties (key?) + if (key.endsWith('?')) { + key = key.slice(0, -1); + } + + if (!key) continue; + + // Resolve the property type + properties[key] = resolvePropertyType(typeStr); + } + + return properties; +} + +/** + * Splits a string by semicolons, respecting nested structures + * @param {string} str - String to split + * @returns {string[]} Array of parts + */ +function splitBySemicolon(str) { + const parts = []; + let current = ''; + let depth = 0; + let parenDepth = 0; + let angleDepth = 0; + + for (let i = 0; i < str.length; i++) { + const char = str[i]; + + if (char === '{') depth++; + else if (char === '}') depth--; + else if (char === '(') parenDepth++; + else if (char === ')') parenDepth--; + else if (char === '<') angleDepth++; + else if (char === '>') angleDepth--; + + if (char === ';' && depth === 0 && parenDepth === 0 && angleDepth === 0) { + parts.push(current.trim()); + current = ''; + } else { + current += char; + } + } + + if (current.trim()) { + parts.push(current.trim()); + } + + return parts; +} + +/** + * Finds the index of the colon separating property name from type + * @param {string} str - Property string + * @returns {number} Index of the colon or -1 + */ +function findPropertyColonIndex(str) { + // Find the first colon that's not inside nested structures + let depth = 0; + let parenDepth = 0; + let angleDepth = 0; + + for (let i = 0; i < str.length; i++) { + const char = str[i]; + + if (char === '{') depth++; + else if (char === '}') depth--; + else if (char === '(') parenDepth++; + else if (char === ')') parenDepth--; + else if (char === '<') angleDepth++; + else if (char === '>') angleDepth--; + else if (char === ':' && depth === 0 && parenDepth === 0 && angleDepth === 0) { + return i; + } + } + + return -1; +} + +/** + * Resolves a property type string to a schema + * @param {string} typeStr - Type string + * @returns {Object} Property schema + */ +function resolvePropertyType(typeStr) { + const trimmed = typeStr.trim(); + + // Handle primitive types + if (['string', 'number', 'boolean', 'any', 'unknown', 'null', 'undefined'].includes(trimmed)) { + return { type: trimmed }; + } + + // Handle array types + if (trimmed.endsWith('[]')) { + const elementType = trimmed.slice(0, -2).trim(); + return { + type: 'array', + items: resolvePropertyType(elementType) + }; + } + + // Handle nested object types + if (trimmed.startsWith('{') && trimmed.endsWith('}')) { + const nestedProps = parseObjectLiteralType(trimmed); + return { + type: 'object', + properties: nestedProps + }; + } + + // For other types, return as-is + return { type: trimmed }; +} + /** * Checks if a type string represents a custom type or interface * @param {string} typeString - Type string to check @@ -129,9 +382,9 @@ function resolveTypeToProperties(checker, typeString, visitedTypes = new Set()) */ function isCustomType(typeString) { // Custom types typically start with uppercase and don't contain certain characters - return typeString[0] === typeString[0].toUpperCase() && - !typeString.includes('<') && - !typeString.includes('|') && + return typeString[0] === typeString[0].toUpperCase() && + !typeString.includes('<') && + !typeString.includes('|') && !typeString.includes('&') && !typeString.includes('(') && !typeString.includes('['); @@ -145,7 +398,7 @@ function isCustomType(typeString) { */ function getBasicTypeOfArrayElement(checker, element) { if (!element || typeof element.kind === 'undefined') return 'any'; - + // Check for literal values first if (ts.isStringLiteral(element)) { return 'string'; @@ -162,10 +415,10 @@ function getBasicTypeOfArrayElement(checker, element) { } else if (element.kind === ts.SyntaxKind.UndefinedKeyword) { return 'undefined'; } - + // For identifiers and other expressions, try to get the type const typeString = getTypeOfNode(checker, element); - + // Extract basic type from TypeScript type string if (typeString.startsWith('"') || typeString.startsWith("'")) { return 'string'; // String literal type @@ -180,7 +433,7 @@ function getBasicTypeOfArrayElement(checker, element) { } else if (isCustomType(typeString)) { return 'object'; } - + return 'any'; } @@ -198,11 +451,337 @@ function isReactHookCall(node, hookNames = ['useCallback', 'useState', 'useEffec return false; } +/** + * Checks if a type is an enum type by examining its symbol + * @param {Object} checker - TypeScript type checker + * @param {string} typeString - Type string to check + * @param {Object} [type] - Optional TypeScript type object + * @returns {boolean} + */ +function isEnumType(checker, typeString, type = null) { + if (!checker || !typeString) return false; + + // Check if it's a simple enum type name (not a union) + if (typeString.includes('|') || typeString.includes('&')) { + return false; + } + + // Skip primitive types + if (['string', 'number', 'boolean', 'any', 'unknown', 'null', 'undefined', 'void'].includes(typeString)) { + return false; + } + + // If we have a type object, check its symbol flags + if (type && type.symbol) { + // Check if the symbol has the Enum flag + if (type.symbol.flags & ts.SymbolFlags.Enum) { + return true; + } + // Check if the symbol has the EnumMember flag (for individual enum values) + if (type.symbol.flags & ts.SymbolFlags.EnumMember) { + return true; + } + } + + return false; +} + +/** + * Gets the values of an enum type from its type object + * @param {Object} checker - TypeScript type checker + * @param {string} typeString - Enum type name + * @param {Object} [type] - Optional TypeScript type object + * @returns {string[]|null} Array of enum values or null + */ +function getEnumValues(checker, typeString, type = null) { + if (!checker || !typeString) return null; + + try { + let enumSymbol = null; + + if (type && type.symbol) { + // Check if this is an enum member (e.g., SubscriptionType.MONTHLY) + if (type.symbol.flags & ts.SymbolFlags.EnumMember) { + // Get the parent enum + enumSymbol = type.symbol.parent; + } + // Check if this is the enum type itself + else if (type.symbol.flags & ts.SymbolFlags.Enum) { + enumSymbol = type.symbol; + } + } + + if (enumSymbol && enumSymbol.exports) { + const values = []; + enumSymbol.exports.forEach((member, name) => { + // Get the value of each enum member + if (member.declarations && member.declarations.length > 0) { + const decl = member.declarations[0]; + if (ts.isEnumMember(decl) && decl.initializer) { + if (ts.isStringLiteral(decl.initializer)) { + values.push(decl.initializer.text); + } else if (ts.isNumericLiteral(decl.initializer)) { + values.push(Number(decl.initializer.text)); + } + } + } + }); + if (values.length > 0) { + return values; + } + } + } catch (e) { + // Ignore errors + } + + return null; +} + +/** + * Resolves a TypeScript type object to a property schema + * This is more accurate than resolving from type strings + * @param {Object} checker - TypeScript type checker + * @param {Object} type - TypeScript Type object + * @param {Set} [visitedTypes] - Set of visited types to prevent cycles + * @returns {Object} Property schema + */ +function resolveTypeObjectToSchema(checker, type, visitedTypes = new Set()) { + if (!type) return { type: 'any' }; + + const typeString = checker.typeToString(type); + + // Prevent infinite recursion + if (visitedTypes.has(typeString)) { + return { type: 'object' }; + } + + // Handle union types + if (type.isUnion?.()) { + return resolveUnionTypeObject(checker, type, visitedTypes); + } + + // Handle enum types (actual enum declarations) + if (isEnumType(checker, typeString, type)) { + const enumValues = getEnumValues(checker, typeString, type); + if (enumValues && enumValues.length > 0) { + return { type: 'enum', values: enumValues }; + } + return { type: 'string' }; + } + + // Handle primitive types + const flags = type.flags; + if (flags & ts.TypeFlags.String || flags & ts.TypeFlags.StringLiteral) { + return { type: 'string' }; + } + if (flags & ts.TypeFlags.Number || flags & ts.TypeFlags.NumberLiteral) { + return { type: 'number' }; + } + if (flags & ts.TypeFlags.Boolean || flags & ts.TypeFlags.BooleanLiteral) { + return { type: 'boolean' }; + } + if (flags & ts.TypeFlags.Undefined) { + return { type: 'undefined' }; + } + if (flags & ts.TypeFlags.Null) { + return { type: 'null' }; + } + + // Handle array types + if (checker.isArrayType?.(type) || typeString.endsWith('[]') || typeString.startsWith('Array<')) { + let elementType = null; + if (type.typeArguments && type.typeArguments.length > 0) { + elementType = type.typeArguments[0]; + } + if (elementType) { + visitedTypes.add(typeString); + return { + type: 'array', + items: resolveTypeObjectToSchema(checker, elementType, visitedTypes) + }; + } + return { type: 'array', items: { type: 'any' } }; + } + + // Handle object types - try to extract properties + if (flags & ts.TypeFlags.Object) { + visitedTypes.add(typeString); + const properties = extractTypeProperties(checker, type, visitedTypes); + if (Object.keys(properties).length > 0) { + return { type: 'object', properties }; + } + return { type: 'object' }; + } + + // Fallback + return resolveTypeToProperties(checker, typeString, visitedTypes); +} + +/** + * Resolves a union type object to a property schema + * Handles string literal unions and optional types + * @param {Object} checker - TypeScript type checker + * @param {Object} type - Union type object + * @param {Set} visitedTypes - Set of visited types + * @returns {Object} Property schema + */ +function resolveUnionTypeObject(checker, type, visitedTypes) { + const types = type.types || []; + + // Filter out undefined and null + const meaningfulTypes = types.filter(t => { + const str = checker.typeToString(t); + return str !== 'undefined' && str !== 'null'; + }); + + if (meaningfulTypes.length === 0) { + return { type: 'null' }; + } + + // Check if all remaining types are string literals -> treat as enum + const allStringLiterals = meaningfulTypes.every(t => t.isStringLiteral?.()); + if (allStringLiterals) { + const values = meaningfulTypes.map(t => getStringLiteralValue(t, checker)); + return { type: 'enum', values }; + } + + // Check if all remaining types are number literals -> treat as enum + const allNumberLiterals = meaningfulTypes.every(t => t.isNumberLiteral?.()); + if (allNumberLiterals) { + const values = meaningfulTypes.map(t => getNumberLiteralValue(t, checker)); + return { type: 'enum', values }; + } + + // If only one meaningful type remains, resolve it + if (meaningfulTypes.length === 1) { + return resolveTypeObjectToSchema(checker, meaningfulTypes[0], visitedTypes); + } + + // Multiple complex types - try to find the most specific one + // Prefer object types + for (const t of meaningfulTypes) { + if (t.flags & ts.TypeFlags.Object) { + return resolveTypeObjectToSchema(checker, t, visitedTypes); + } + } + + // Fallback to type string + const typeString = checker.typeToString(type); + return { type: typeString }; +} + +/** + * Gets the actual string value from a string literal type + * Handles both regular string literals and enum member string literals + * @param {Object} type - TypeScript type + * @param {Object} checker - TypeScript type checker + * @returns {string} The actual string value + */ +function getStringLiteralValue(type, checker) { + // Check if this is an enum member - get the actual value from the initializer + if (type.symbol && (type.symbol.flags & ts.SymbolFlags.EnumMember)) { + const valueDecl = type.symbol.valueDeclaration; + if (valueDecl && ts.isEnumMember(valueDecl) && valueDecl.initializer) { + if (ts.isStringLiteral(valueDecl.initializer)) { + return valueDecl.initializer.text; + } + } + } + + // For regular string literals, remove quotes from the type string + const str = checker.typeToString(type); + return str.replace(/^["']|["']$/g, ''); +} + +/** + * Gets the actual number value from a number literal type + * @param {Object} type - TypeScript type + * @param {Object} checker - TypeScript type checker + * @returns {number} The actual number value + */ +function getNumberLiteralValue(type, checker) { + // Check if this is an enum member - get the actual value from the initializer + if (type.symbol && (type.symbol.flags & ts.SymbolFlags.EnumMember)) { + const valueDecl = type.symbol.valueDeclaration; + if (valueDecl && ts.isEnumMember(valueDecl) && valueDecl.initializer) { + if (ts.isNumericLiteral(valueDecl.initializer)) { + return Number(valueDecl.initializer.text); + } + } + } + + return Number(checker.typeToString(type)); +} + +/** + * Extracts properties from a TypeScript type object + * @param {Object} checker - TypeScript type checker + * @param {Object} type - TypeScript Type object + * @param {Set} visitedTypes - Set of visited types + * @returns {Object} Properties map + */ +function extractTypeProperties(checker, type, visitedTypes) { + const properties = {}; + + try { + const members = checker.getPropertiesOfType(type); + + for (const member of members) { + const name = member.name; + + // Skip functions and common built-in methods + if (STRING_PROTOTYPE_METHODS.has(name) || name.startsWith('__@')) { + continue; + } + + try { + const memberType = checker.getTypeOfSymbolAtLocation(member, member.valueDeclaration); + const memberTypeString = checker.typeToString(memberType); + + // Skip function types + if (memberTypeString.includes('=>') || memberTypeString.startsWith('(')) { + continue; + } + + // Recursively resolve the member type + const resolved = resolveTypeObjectToSchema(checker, memberType, visitedTypes); + properties[name] = resolved; + } catch (e) { + properties[name] = { type: 'any' }; + } + } + } catch (e) { + // Ignore errors + } + + return properties; +} + +/** + * Set of string prototype methods to filter out + */ +const STRING_PROTOTYPE_METHODS = new Set([ + 'toString', 'charAt', 'charCodeAt', 'concat', 'indexOf', 'lastIndexOf', + 'localeCompare', 'match', 'replace', 'search', 'slice', 'split', + 'substring', 'toLowerCase', 'toLocaleLowerCase', 'toUpperCase', + 'toLocaleUpperCase', 'trim', 'length', 'substr', 'valueOf', + 'codePointAt', 'includes', 'endsWith', 'normalize', 'repeat', + 'startsWith', 'anchor', 'big', 'blink', 'bold', 'fixed', + 'fontcolor', 'fontsize', 'italics', 'link', 'small', 'strike', + 'sub', 'sup', 'padStart', 'padEnd', 'trimEnd', 'trimStart', + 'trimLeft', 'trimRight', 'matchAll', 'replaceAll', 'at', + 'isWellFormed', 'toWellFormed' +]); + module.exports = { resolveIdentifierToInitializer, getTypeOfNode, resolveTypeToProperties, isCustomType, getBasicTypeOfArrayElement, - isReactHookCall + isReactHookCall, + isEnumType, + getEnumValues, + resolveTypeObjectToSchema, + extractTypeProperties }; diff --git a/src/analyze/utils/customFunctionParser.js b/src/analyze/utils/customFunctionParser.js index ea41e07..dec50bc 100644 --- a/src/analyze/utils/customFunctionParser.js +++ b/src/analyze/utils/customFunctionParser.js @@ -6,6 +6,35 @@ function parseCustomFunctionSignature(signature) { const trimmed = signature.trim(); + // Check for method-as-event pattern: objectName.EVENT_NAME(PROPERTIES) + // This pattern means the method name itself is the event name + const methodAsEventMatch = trimmed.match(/^([^.]+)\.EVENT_NAME\s*\(([^)]*)\)\s*$/); + if (methodAsEventMatch) { + const objectName = methodAsEventMatch[1].trim(); + const paramsPart = methodAsEventMatch[2].trim(); + + // Parse the parameters inside EVENT_NAME(...) + const params = paramsPart ? paramsPart.split(',').map(p => p.trim()).filter(Boolean) : []; + + // Find PROPERTIES index (default to 0 if not specified) + let propertiesIndex = params.findIndex(p => p.toUpperCase() === 'PROPERTIES'); + if (propertiesIndex === -1) { + // If PROPERTIES is not explicitly listed, it's the first parameter (index 0) + propertiesIndex = 0; + } + + const extraParams = params.map((name, idx) => ({ idx, name })) + .filter(p => p.idx !== propertiesIndex); + + return { + functionName: objectName, // Use objectName for matching the object part + objectName, // Store separately for clarity + isMethodAsEvent: true, // Flag indicating method name is event name + propertiesIndex, + extraParams + }; + } + // Two cases: // 1) Full signature with params at the end (e.g., Module.track(EVENT_NAME, PROPERTIES)) → parse params // 2) Name-only (including chains with internal calls, e.g., getService().track) → no params diff --git a/tests/analyzeJavaScript.test.js b/tests/analyzeJavaScript.test.js index 41ea75c..fd5826d 100644 --- a/tests/analyzeJavaScript.test.js +++ b/tests/analyzeJavaScript.test.js @@ -7,17 +7,17 @@ const { parseCustomFunctionSignature } = require('../src/analyze/utils/customFun test.describe('analyzeJsFile', () => { const fixturesDir = path.join(__dirname, 'fixtures'); const testFilePath = path.join(fixturesDir, 'javascript', 'main.js'); - + test('should correctly analyze JavaScript file with multiple tracking providers', () => { const customFunction = 'customTrackFunction(userId, EVENT_NAME, PROPERTIES)'; const customFunctionSignatures = [parseCustomFunctionSignature(customFunction)]; const events = analyzeJsFile(testFilePath, customFunctionSignatures); - + // Sort events by line number for consistent ordering events.sort((a, b) => a.line - b.line); - + assert.strictEqual(events.length, 19); - + // Test Google Analytics event const gaEvent = events.find(e => e.eventName === 'purchase' && e.source === 'googleanalytics'); assert.ok(gaEvent); @@ -36,7 +36,7 @@ test.describe('analyzeJsFile', () => { } } }); - + // Test Segment event const segmentEvent = events.find(e => e.eventName === 'newEvent'); assert.ok(segmentEvent); @@ -47,7 +47,7 @@ test.describe('analyzeJsFile', () => { something: { type: 'string' }, count: { type: 'number' } }); - + // Test Mixpanel event const mixpanelEvent = events.find(e => e.eventName === 'orderCompleted'); assert.ok(mixpanelEvent); @@ -59,7 +59,7 @@ test.describe('analyzeJsFile', () => { products: { type: 'any' }, total: { type: 'any' } }); - + // Test Amplitude event const amplitudeEvent = events.find(e => e.eventName === 'checkout' && e.source === 'amplitude'); assert.ok(amplitudeEvent); @@ -78,7 +78,7 @@ test.describe('analyzeJsFile', () => { } } }); - + // Test Rudderstack event const rudderstackEvent = events.find(e => e.eventName === 'Order Completed'); assert.ok(rudderstackEvent); @@ -97,7 +97,7 @@ test.describe('analyzeJsFile', () => { } } }); - + // Test mParticle event const mparticleEvent = events.find(e => e.eventName === 'Buy Now'); assert.ok(mparticleEvent); @@ -116,7 +116,7 @@ test.describe('analyzeJsFile', () => { } } }); - + // Test PostHog event const posthogEvent = events.find(e => e.eventName === 'user click'); assert.ok(posthogEvent); @@ -136,7 +136,7 @@ test.describe('analyzeJsFile', () => { } } }); - + // Test Pendo event const pendoEvent = events.find(e => e.eventName === 'customer checkout'); assert.ok(pendoEvent); @@ -158,7 +158,7 @@ test.describe('analyzeJsFile', () => { } } }); - + // Test Heap event const heapEvent = events.find(e => e.eventName === 'login'); assert.ok(heapEvent); @@ -170,7 +170,7 @@ test.describe('analyzeJsFile', () => { email: { type: 'string' }, name: { type: 'string' } }); - + // Test Snowplow event const snowplowEvent = events.find(e => e.eventName === 'someevent'); assert.ok(snowplowEvent); @@ -183,7 +183,7 @@ test.describe('analyzeJsFile', () => { property: { type: 'string' }, value: { type: 'any' } }); - + // Test custom function event const customEvent = events.find(e => e.eventName === 'customEvent'); assert.ok(customEvent); @@ -199,7 +199,7 @@ test.describe('analyzeJsFile', () => { items: { type: 'string' } } }); - + // Test frozen constant event name via Object.freeze constant const frozenEvent = events.find(e => e.eventName === 'ecommerce_purchase_frozen'); assert.ok(frozenEvent); @@ -214,7 +214,7 @@ test.describe('analyzeJsFile', () => { } }); }); - + test('should handle files without tracking events', () => { const emptyTestFile = path.join(fixturesDir, 'javascript', 'empty.js'); // Create empty file for testing @@ -222,25 +222,25 @@ test.describe('analyzeJsFile', () => { if (!fs.existsSync(emptyTestFile)) { fs.writeFileSync(emptyTestFile, '// Empty file\n'); } - + const customFunctionSignatures = [parseCustomFunctionSignature('customTrack')]; const events = analyzeJsFile(emptyTestFile, customFunctionSignatures); assert.deepStrictEqual(events, []); }); - + test('should handle missing custom function', () => { const events = analyzeJsFile(testFilePath, null); - + // Should find all events except the custom one assert.strictEqual(events.length, 18); assert.strictEqual(events.find(e => e.source === 'custom'), undefined); }); - + test('should handle nested property types correctly', () => { const customFunction = 'customTrackFunction(userId, EVENT_NAME, PROPERTIES)'; const customFunctionSignatures = [parseCustomFunctionSignature(customFunction)]; const events = analyzeJsFile(testFilePath, customFunctionSignatures); - + // Test nested object properties const eventWithNestedObj = events.find(e => e.properties.address); assert.ok(eventWithNestedObj); @@ -251,7 +251,7 @@ test.describe('analyzeJsFile', () => { state: { type: 'string' } } }); - + // Test array properties const eventWithArray = events.find(e => e.properties.list); assert.ok(eventWithArray); @@ -260,12 +260,12 @@ test.describe('analyzeJsFile', () => { items: { type: 'string' } }); }); - + test('should detect array types correctly', () => { const customFunction = 'customTrackFunction(userId, EVENT_NAME, PROPERTIES)'; const customFunctionSignatures = [parseCustomFunctionSignature(customFunction)]; const events = analyzeJsFile(testFilePath, customFunctionSignatures); - + // Test array of objects const pendoEvent = events.find(e => e.eventName === 'customer checkout'); assert.ok(pendoEvent); @@ -273,7 +273,7 @@ test.describe('analyzeJsFile', () => { type: 'array', items: { type: 'object' } }); - + // Test array of strings const customEvent = events.find(e => e.eventName === 'customEvent'); assert.ok(customEvent); @@ -282,45 +282,45 @@ test.describe('analyzeJsFile', () => { items: { type: 'string' } }); }); - + test('should handle different function contexts correctly', () => { const customFunction = 'customTrackFunction(userId, EVENT_NAME, PROPERTIES)'; const customFunctionSignatures = [parseCustomFunctionSignature(customFunction)]; const events = analyzeJsFile(testFilePath, customFunctionSignatures); - + // Test function declaration const funcDeclEvent = events.find(e => e.functionName === 'test12345678'); assert.ok(funcDeclEvent); - + // Test arrow function const arrowFuncEvent = events.find(e => e.functionName === 'trackGA4'); assert.ok(arrowFuncEvent); - + // Test class method const classMethodEvent = events.find(e => e.functionName === 'trackSnowplow'); assert.ok(classMethodEvent); - + // Test global scope const globalEvent = events.find(e => e.functionName === 'global'); assert.ok(globalEvent); }); - + test('should handle case variations in provider names', () => { const customFunction = 'customTrackFunction(userId, EVENT_NAME, PROPERTIES)'; const customFunctionSignatures = [parseCustomFunctionSignature(customFunction)]; const events = analyzeJsFile(testFilePath, customFunctionSignatures); - + // mParticle is used with lowercase 'p' in the test file const mparticleEvent = events.find(e => e.source === 'mparticle'); assert.ok(mparticleEvent); assert.strictEqual(mparticleEvent.eventName, 'Buy Now'); }); - + test('should exclude action field from Snowplow properties', () => { const customFunction = 'customTrackFunction(userId, EVENT_NAME, PROPERTIES)'; const customFunctionSignatures = [parseCustomFunctionSignature(customFunction)]; const events = analyzeJsFile(testFilePath, customFunctionSignatures); - + const snowplowEvent = events.find(e => e.source === 'snowplow'); assert.ok(snowplowEvent); assert.strictEqual(snowplowEvent.eventName, 'someevent'); @@ -328,39 +328,43 @@ test.describe('analyzeJsFile', () => { assert.strictEqual(snowplowEvent.properties.action, undefined); assert.ok(snowplowEvent.properties.category); }); - + test('should handle mParticle three-parameter format', () => { const customFunction = 'customTrackFunction(userId, EVENT_NAME, PROPERTIES)'; const customFunctionSignatures = [parseCustomFunctionSignature(customFunction)]; const events = analyzeJsFile(testFilePath, customFunctionSignatures); - + const mparticleEvent = events.find(e => e.source === 'mparticle'); assert.ok(mparticleEvent); assert.strictEqual(mparticleEvent.eventName, 'Buy Now'); // Event name is first param, properties are third param assert.ok(mparticleEvent.properties.order_id); }); - + test('should detect events for all custom function signature variations', () => { + const methodEventFile = path.join(fixturesDir, 'javascript', 'method-event.js'); const variants = [ - { sig: 'customTrackFunction0', event: 'custom_event0' }, - { sig: 'customTrackFunction1(EVENT_NAME, PROPERTIES)', event: 'custom_event1' }, - { sig: 'customTrackFunction2(userId, EVENT_NAME, PROPERTIES)', event: 'custom_event2' }, - { sig: 'customTrackFunction3(EVENT_NAME, PROPERTIES, userEmail)', event: 'custom_event3' }, - { sig: 'customTrackFunction4(userId, EVENT_NAME, userAddress, PROPERTIES, userEmail)', event: 'custom_event4' }, - { sig: 'CustomModule.track(userId, EVENT_NAME, PROPERTIES)', event: 'custom_module_event' }, - { sig: 'getTrackingService().track(EVENT_NAME, PROPERTIES)', event: 'myChainedEvent' }, + { sig: 'customTrackFunction0', event: 'custom_event0', file: testFilePath }, + { sig: 'customTrackFunction1(EVENT_NAME, PROPERTIES)', event: 'custom_event1', file: testFilePath }, + { sig: 'customTrackFunction2(userId, EVENT_NAME, PROPERTIES)', event: 'custom_event2', file: testFilePath }, + { sig: 'customTrackFunction3(EVENT_NAME, PROPERTIES, userEmail)', event: 'custom_event3', file: testFilePath }, + { sig: 'customTrackFunction4(userId, EVENT_NAME, userAddress, PROPERTIES, userEmail)', event: 'custom_event4', file: testFilePath }, + { sig: 'CustomModule.track(userId, EVENT_NAME, PROPERTIES)', event: 'custom_module_event', file: testFilePath }, + { sig: 'getTrackingService().track(EVENT_NAME, PROPERTIES)', event: 'myChainedEvent', file: testFilePath }, + // Method-as-event signature + { sig: 'eventCalls.EVENT_NAME(PROPERTIES)', event: 'viewItemList', file: methodEventFile }, ]; - variants.forEach(({ sig, event }) => { + variants.forEach(({ sig, event, file }) => { const customFunctionSignatures = [parseCustomFunctionSignature(sig)]; - const events = analyzeJsFile(testFilePath, customFunctionSignatures); + const events = analyzeJsFile(file, customFunctionSignatures); const found = events.find(e => e.eventName === event && e.source === 'custom'); assert.ok(found, `Should detect ${event} for signature ${sig}`); }); }); - + test('should detect events when multiple custom function signatures are provided together', () => { + const methodEventFile = path.join(fixturesDir, 'javascript', 'method-event.js'); const variants = [ 'customTrackFunction(userId, EVENT_NAME, PROPERTIES)', 'customTrackFunction0', @@ -395,6 +399,16 @@ test.describe('analyzeJsFile', () => { // Sanity check – ensure we did not lose built-in provider events const builtInProvidersCount = events.filter(e => e.source !== 'custom').length; assert.ok(builtInProvidersCount >= 10, 'Should still include built-in events'); + + // Test method-as-event signature separately (different file) + const methodAsEventSignatures = [parseCustomFunctionSignature('eventCalls.EVENT_NAME(PROPERTIES)')]; + const methodEvents = analyzeJsFile(methodEventFile, methodAsEventSignatures); + + const methodEventNames = ['viewItemList', 'addToCart', 'removeFromCart', 'beginCheckout', 'purchase', 'pageView']; + methodEventNames.forEach(eventName => { + const evt = methodEvents.find(e => e.eventName === eventName && e.source === 'custom'); + assert.ok(evt, `Expected to find method-as-event ${eventName}`); + }); }); test('should detect events with no properties for custom function', () => { @@ -447,4 +461,95 @@ test.describe('analyzeJsFile', () => { SectionName: { type: 'any' } }); }); + + test('should detect method-as-event custom functions', () => { + const methodEventFile = path.join(fixturesDir, 'javascript', 'method-event.js'); + const customFunction = 'eventCalls.EVENT_NAME(PROPERTIES)'; + const events = analyzeJsFile(methodEventFile, [parseCustomFunctionSignature(customFunction)]); + + assert.ok(events.length >= 5, 'Should detect multiple method-as-event calls'); + + // Test viewItemList event + const viewItemList = events.find(e => e.eventName === 'viewItemList'); + assert.ok(viewItemList, 'Should detect viewItemList event'); + assert.strictEqual(viewItemList.source, 'custom'); + assert.strictEqual(viewItemList.functionName, 'global'); + assert.deepStrictEqual(viewItemList.properties, { + items: { + type: 'array', + items: { type: 'object' } + }, + item_list_id: { type: 'string' }, + item_list_name: { type: 'string' } + }); + + // Test addToCart event + const addToCart = events.find(e => e.eventName === 'addToCart'); + assert.ok(addToCart, 'Should detect addToCart event'); + assert.strictEqual(addToCart.source, 'custom'); + assert.strictEqual(addToCart.functionName, 'handleAddToCart'); + assert.deepStrictEqual(addToCart.properties, { + items: { + type: 'array', + items: { type: 'object' } + }, + value: { type: 'number' }, + user: { + type: 'object', + properties: { + id: { type: 'string' }, + email: { type: 'string' }, + name: { type: 'string' } + } + } + }); + + // Test removeFromCart event + const removeFromCart = events.find(e => e.eventName === 'removeFromCart'); + assert.ok(removeFromCart, 'Should detect removeFromCart event'); + assert.strictEqual(removeFromCart.source, 'custom'); + + // Test beginCheckout event + const beginCheckout = events.find(e => e.eventName === 'beginCheckout'); + assert.ok(beginCheckout, 'Should detect beginCheckout event'); + assert.strictEqual(beginCheckout.source, 'custom'); + assert.strictEqual(beginCheckout.functionName, 'checkoutHandler'); + assert.deepStrictEqual(beginCheckout.properties, { + items: { + type: 'array', + items: { type: 'object' } + }, + currency: { type: 'string' }, + value: { type: 'number' } + }); + + // Test purchase event with nested objects + const purchase = events.find(e => e.eventName === 'purchase'); + assert.ok(purchase, 'Should detect purchase event'); + assert.ok(purchase.properties.shipping, 'Should include nested shipping property'); + assert.strictEqual(purchase.properties.shipping.type, 'object'); + assert.ok(purchase.properties.shipping.properties.address, 'Should include nested address property'); + + // Test pageView with empty properties + const pageView = events.find(e => e.eventName === 'pageView'); + assert.ok(pageView, 'Should detect pageView event'); + assert.deepStrictEqual(pageView.properties, {}, 'Should handle empty properties object'); + }); + + test('should handle method-as-event alongside standard custom functions', () => { + const methodEventFile = path.join(fixturesDir, 'javascript', 'method-event.js'); + const customFunctions = [ + 'eventCalls.EVENT_NAME(PROPERTIES)', + 'customTrackFunction(userId, EVENT_NAME, PROPERTIES)' + ]; + const events = analyzeJsFile(methodEventFile, customFunctions.map(parseCustomFunctionSignature)); + + // Should detect method-as-event calls + const viewItemList = events.find(e => e.eventName === 'viewItemList' && e.source === 'custom'); + assert.ok(viewItemList, 'Should detect method-as-event calls'); + + // Should not detect standard custom function calls (none in this file) + const customEvents = events.filter(e => e.source === 'custom'); + assert.ok(customEvents.length >= 5, 'Should detect multiple method-as-event events'); + }); }); diff --git a/tests/analyzeTypeScript.test.js b/tests/analyzeTypeScript.test.js index 02bb196..7fbd125 100644 --- a/tests/analyzeTypeScript.test.js +++ b/tests/analyzeTypeScript.test.js @@ -56,7 +56,7 @@ test.describe('analyzeTsFile', () => { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, - sku: { type: 'string | undefined' } + sku: { type: 'string' } } } }, @@ -66,7 +66,7 @@ test.describe('analyzeTsFile', () => { properties: { city: { type: 'string' }, state: { type: 'string' }, - postalCode: { type: 'string | undefined' } + postalCode: { type: 'string' } } }, currency: { type: 'string' } @@ -100,7 +100,7 @@ test.describe('analyzeTsFile', () => { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, - sku: { type: 'string | undefined' } + sku: { type: 'string' } } } }, @@ -123,7 +123,7 @@ test.describe('analyzeTsFile', () => { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, - sku: { type: 'string | undefined' } + sku: { type: 'string' } } } }, @@ -133,7 +133,7 @@ test.describe('analyzeTsFile', () => { properties: { city: { type: 'string' }, state: { type: 'string' }, - postalCode: { type: 'string | undefined' } + postalCode: { type: 'string' } } }, coupon_code: { type: 'null' } @@ -155,7 +155,7 @@ test.describe('analyzeTsFile', () => { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, - sku: { type: 'string | undefined' } + sku: { type: 'string' } } } }, @@ -165,7 +165,7 @@ test.describe('analyzeTsFile', () => { properties: { city: { type: 'string' }, state: { type: 'string' }, - postalCode: { type: 'string | undefined' } + postalCode: { type: 'string' } } } }); @@ -186,7 +186,7 @@ test.describe('analyzeTsFile', () => { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, - sku: { type: 'string | undefined' } + sku: { type: 'string' } } } }, @@ -196,7 +196,7 @@ test.describe('analyzeTsFile', () => { properties: { city: { type: 'string' }, state: { type: 'string' }, - postalCode: { type: 'string | undefined' } + postalCode: { type: 'string' } } } }); @@ -218,7 +218,7 @@ test.describe('analyzeTsFile', () => { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, - sku: { type: 'string | undefined' } + sku: { type: 'string' } } } }, @@ -228,7 +228,7 @@ test.describe('analyzeTsFile', () => { properties: { city: { type: 'string' }, state: { type: 'string' }, - postalCode: { type: 'string | undefined' } + postalCode: { type: 'string' } } } }); @@ -249,7 +249,7 @@ test.describe('analyzeTsFile', () => { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, - sku: { type: 'string | undefined' } + sku: { type: 'string' } } } }, @@ -259,7 +259,7 @@ test.describe('analyzeTsFile', () => { properties: { city: { type: 'string' }, state: { type: 'string' }, - postalCode: { type: 'string | undefined' } + postalCode: { type: 'string' } } } }); @@ -433,7 +433,7 @@ test.describe('analyzeTsFile', () => { properties: { city: { type: 'string' }, state: { type: 'string' }, - postalCode: { type: 'string | undefined' } + postalCode: { type: 'string' } } }); @@ -448,7 +448,7 @@ test.describe('analyzeTsFile', () => { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, - sku: { type: 'string | undefined' } + sku: { type: 'string' } } } }); @@ -468,7 +468,7 @@ test.describe('analyzeTsFile', () => { assert.ok(addressProp.properties); assert.strictEqual(addressProp.properties.city.type, 'string'); assert.strictEqual(addressProp.properties.state.type, 'string'); - assert.strictEqual(addressProp.properties.postalCode.type, 'string | undefined'); + assert.strictEqual(addressProp.properties.postalCode.type, 'string'); // Test that Product interface is expanded in arrays const eventWithProducts = events.find(e => e.properties.items || e.properties.products); @@ -480,7 +480,7 @@ test.describe('analyzeTsFile', () => { assert.strictEqual(productsProp.items.properties.id.type, 'string'); assert.strictEqual(productsProp.items.properties.name.type, 'string'); assert.strictEqual(productsProp.items.properties.price.type, 'number'); - assert.strictEqual(productsProp.items.properties.sku.type, 'string | undefined'); + assert.strictEqual(productsProp.items.properties.sku.type, 'string'); }); test('should handle shorthand property assignments correctly', () => { @@ -630,7 +630,7 @@ test.describe('analyzeTsFile', () => { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, - sku: { type: 'string | undefined' } + sku: { type: 'string' } } }, cart_size: { type: 'number' } @@ -658,7 +658,7 @@ test.describe('analyzeTsFile', () => { type: 'array', items: { type: 'object', - properties: { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, sku: { type: 'string | undefined' } } + properties: { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, sku: { type: 'string' } } } }, value: { type: 'number' }, @@ -676,7 +676,7 @@ test.describe('analyzeTsFile', () => { type: 'array', items: { type: 'object', - properties: { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, sku: { type: 'string | undefined' } } + properties: { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, sku: { type: 'string' } } } }, total_items: { type: 'number' } @@ -693,7 +693,7 @@ test.describe('analyzeTsFile', () => { type: 'array', items: { type: 'object', - properties: { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, sku: { type: 'string | undefined' } } + properties: { id: { type: 'string' }, name: { type: 'string' }, price: { type: 'number' }, sku: { type: 'string' } } } }, checkout_step: { type: 'number' } @@ -737,7 +737,7 @@ test.describe('analyzeTsFile', () => { test('should handle complex React class component patterns without crashing (regression test)', () => { const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); const program = createProgram(reactFilePath); - + // This should not throw any errors - the main test is that it doesn't crash assert.doesNotThrow(() => { const events = analyzeTsFile(reactFilePath, program); @@ -750,13 +750,13 @@ test.describe('analyzeTsFile', () => { test('should handle complex class component with custom function detection without crashing', () => { const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); const program = createProgram(reactFilePath); - + // This was the specific case that was causing "Cannot read properties of undefined (reading 'kind')" assert.doesNotThrow(() => { const customFunctionSignatures = [parseCustomFunctionSignature('track')]; const events = analyzeTsFile(reactFilePath, program, customFunctionSignatures); assert.ok(Array.isArray(events)); - + // Should find the analytics.track call when looking for 'track' custom function const analyticsEvent = events.find(e => e.eventName === 'document_upload_clicked'); assert.ok(analyticsEvent); @@ -772,11 +772,11 @@ test.describe('analyzeTsFile', () => { test('should handle various custom function detection patterns without undefined errors', () => { const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); const program = createProgram(reactFilePath); - + // Test various custom function patterns that could trigger the bug const customFunctionTests = [ 'track', - 'analytics.track', + 'analytics.track', 'tracker.track', 'this.track', 'mixpanel.track', @@ -795,7 +795,7 @@ test.describe('analyzeTsFile', () => { test('should handle nested property access expressions in custom function detection', () => { const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); const program = createProgram(reactFilePath); - + // Test deeply nested property access that could cause undefined node traversal const complexCustomFunctions = [ 'this.props.analytics.track', @@ -816,9 +816,9 @@ test.describe('analyzeTsFile', () => { test('should correctly identify React class method contexts without undefined errors', () => { const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); const program = createProgram(reactFilePath); - + const events = analyzeTsFile(reactFilePath, program); - + // Should find the analytics.track call in the arrow function method const analyticsEvent = events.find(e => e.eventName === 'document_upload_clicked'); assert.ok(analyticsEvent); @@ -829,7 +829,7 @@ test.describe('analyzeTsFile', () => { test('should handle TypeScript React component with complex type intersections', () => { const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); const program = createProgram(reactFilePath); - + // The file has complex type intersections: MappedProps & ExplicitProps & ActionProps // This should not cause AST traversal issues assert.doesNotThrow(() => { @@ -842,7 +842,7 @@ test.describe('analyzeTsFile', () => { test('should handle React refs and generic type parameters without errors', () => { const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); const program = createProgram(reactFilePath); - + // The file uses React.createRef() which creates complex AST nodes assert.doesNotThrow(() => { const customFunctionSignatures = [parseCustomFunctionSignature('open')]; @@ -854,21 +854,21 @@ test.describe('analyzeTsFile', () => { test('should handle both React functional and class components correctly', () => { const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); const program = createProgram(reactFilePath); - + // Should work without errors for file containing both patterns assert.doesNotThrow(() => { const customFunctionSignatures = [parseCustomFunctionSignature('track')]; const events = analyzeTsFile(reactFilePath, program, customFunctionSignatures); - + assert.ok(Array.isArray(events)); - + // Should have events from both functional and class components assert.ok(events.length > 0); - + // Should have functional component events (from hooks) const functionalEvents = events.filter(e => e.functionName.includes('useCallback') || e.functionName.includes('useEffect')); assert.ok(functionalEvents.length > 0); - + // Should have class component events (they now show proper method names) const classEvents = events.filter(e => e.functionName === 'onFileUploadClick' || e.functionName === 'handleComplexOperation'); assert.ok(classEvents.length > 0); @@ -878,7 +878,7 @@ test.describe('analyzeTsFile', () => { test('should handle edge cases in isCustomFunction without undefined property access', () => { const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); const program = createProgram(reactFilePath); - + // These edge cases were specifically causing the "reading 'kind'" error const edgeCaseCustomFunctions = [ 'track', // matches .track in analytics.track @@ -899,13 +899,13 @@ test.describe('analyzeTsFile', () => { test('should preserve correct event extraction while fixing undefined errors', () => { const reactFilePath = path.join(fixturesDir, 'typescript-react', 'main.tsx'); const program = createProgram(reactFilePath); - + // Verify that our fix doesn't break the actual tracking detection const events = analyzeTsFile(reactFilePath, program); - + // Should correctly identify multiple tracking events including the complex class component assert.ok(events.length >= 8); - + // Should still correctly identify the analytics.track call from complex component const complexEvent = events.find(e => e.eventName === 'document_upload_clicked'); assert.ok(complexEvent); @@ -919,22 +919,25 @@ test.describe('analyzeTsFile', () => { }); test('should detect events for all custom function signature variations', () => { + const methodEventFile = path.join(fixturesDir, 'typescript', 'method-event.ts'); const variants = [ - { sig: 'customTrackFunction0', event: 'custom_event0' }, - { sig: 'customTrackFunction1(EVENT_NAME, PROPERTIES)', event: 'custom_event1' }, - { sig: 'customTrackFunction2(userId, EVENT_NAME, PROPERTIES)', event: 'custom_event2' }, - { sig: 'customTrackFunction3(EVENT_NAME, PROPERTIES, userEmail)', event: 'custom_event3' }, - { sig: 'customTrackFunction4(userId, EVENT_NAME, userAddress, PROPERTIES, userEmail)', event: 'custom_event4' }, - { sig: 'CustomModule.track(userId, EVENT_NAME, PROPERTIES)', event: 'custom_module_event' }, - { sig: 'customTrackFunction5', event: 'FailedPayment' }, - { sig: 'this.props.customTrackFunction6(EVENT_NAME, PROPERTIES)', event: 'ViewedAttorneyAgreement' }, - { sig: 'customTrackFunction7(EVENT_NAME, PROPERTIES)', event: 'InitiatedPayment' }, + { sig: 'customTrackFunction0', event: 'custom_event0', file: testFilePath }, + { sig: 'customTrackFunction1(EVENT_NAME, PROPERTIES)', event: 'custom_event1', file: testFilePath }, + { sig: 'customTrackFunction2(userId, EVENT_NAME, PROPERTIES)', event: 'custom_event2', file: testFilePath }, + { sig: 'customTrackFunction3(EVENT_NAME, PROPERTIES, userEmail)', event: 'custom_event3', file: testFilePath }, + { sig: 'customTrackFunction4(userId, EVENT_NAME, userAddress, PROPERTIES, userEmail)', event: 'custom_event4', file: testFilePath }, + { sig: 'CustomModule.track(userId, EVENT_NAME, PROPERTIES)', event: 'custom_module_event', file: testFilePath }, + { sig: 'customTrackFunction5', event: 'FailedPayment', file: testFilePath }, + { sig: 'this.props.customTrackFunction6(EVENT_NAME, PROPERTIES)', event: 'ViewedAttorneyAgreement', file: testFilePath }, + { sig: 'customTrackFunction7(EVENT_NAME, PROPERTIES)', event: 'InitiatedPayment', file: testFilePath }, + // Method-as-event signature + { sig: 'eventCalls.EVENT_NAME(PROPERTIES)', event: 'viewItemList', file: methodEventFile }, ]; - variants.forEach(({ sig, event }) => { - const program = createProgram(testFilePath); + variants.forEach(({ sig, event, file }) => { + const program = createProgram(file); const customFunctionSignatures = [parseCustomFunctionSignature(sig)]; - const events = analyzeTsFile(testFilePath, program, customFunctionSignatures); + const events = analyzeTsFile(file, program, customFunctionSignatures); const found = events.find(e => e.eventName === event && e.source === 'custom'); assert.ok(found, `Should detect ${event} for signature ${sig}`); }); @@ -980,6 +983,18 @@ test.describe('analyzeTsFile', () => { // Ensure built-in provider events remain unaffected const builtInCount = events.filter(e => e.source !== 'custom').length; assert.ok(builtInCount >= 10, 'Should still include built-in provider events'); + + // Test method-as-event signature separately (different file) + const methodEventFile = path.join(fixturesDir, 'typescript', 'method-event.ts'); + const methodProgram = createProgram(methodEventFile); + const methodAsEventSignatures = [parseCustomFunctionSignature('eventCalls.EVENT_NAME(PROPERTIES)')]; + const methodEvents = analyzeTsFile(methodEventFile, methodProgram, methodAsEventSignatures); + + const methodEventNames = ['viewItemList', 'addToCart', 'removeFromCart', 'beginCheckout', 'purchase', 'pageView', 'complexOperation']; + methodEventNames.forEach(eventName => { + const evt = methodEvents.find(e => e.eventName === eventName && e.source === 'custom'); + assert.ok(evt, `Expected to find method-as-event ${eventName}`); + }); }); test('should resolve constants imported via path alias from tsconfig', () => { @@ -1013,4 +1028,97 @@ test.describe('analyzeTsFile', () => { assert.ok(constantEvent); assert.deepStrictEqual(constantEvent.properties, {}); }); + + test('should detect method-as-event custom functions', () => { + const methodEventFile = path.join(fixturesDir, 'typescript', 'method-event.ts'); + const program = createProgram(methodEventFile); + const customFunction = 'eventCalls.EVENT_NAME(PROPERTIES)'; + const events = analyzeTsFile(methodEventFile, program, [parseCustomFunctionSignature(customFunction)]); + + assert.ok(events.length >= 5, 'Should detect multiple method-as-event calls'); + + // Test viewItemList event + const viewItemList = events.find(e => e.eventName === 'viewItemList'); + assert.ok(viewItemList, 'Should detect viewItemList event'); + assert.strictEqual(viewItemList.source, 'custom'); + assert.strictEqual(viewItemList.functionName, 'global'); + assert.deepStrictEqual(viewItemList.properties, { + items: { + type: 'array', + items: { type: 'object' } + }, + item_list_id: { type: 'string' }, + item_list_name: { type: 'string' } + }); + + // Test addToCart event + const addToCart = events.find(e => e.eventName === 'addToCart'); + assert.ok(addToCart, 'Should detect addToCart event'); + assert.strictEqual(addToCart.source, 'custom'); + assert.strictEqual(addToCart.functionName, 'handleAddToCart'); + assert.deepStrictEqual(addToCart.properties, { + items: { + type: 'array', + items: { type: 'object' } + }, + value: { type: 'number' }, + user: { + type: 'object', + properties: { + id: { type: 'string' }, + email: { type: 'string' }, + name: { type: 'string' } + } + } + }); + + // Test removeFromCart event + const removeFromCart = events.find(e => e.eventName === 'removeFromCart'); + assert.ok(removeFromCart, 'Should detect removeFromCart event'); + assert.strictEqual(removeFromCart.source, 'custom'); + + // Test beginCheckout event + const beginCheckout = events.find(e => e.eventName === 'beginCheckout'); + assert.ok(beginCheckout, 'Should detect beginCheckout event'); + assert.strictEqual(beginCheckout.source, 'custom'); + assert.strictEqual(beginCheckout.functionName, 'checkoutHandler'); + assert.deepStrictEqual(beginCheckout.properties, { + items: { + type: 'array', + items: { type: 'object' } + }, + currency: { type: 'string' }, + value: { type: 'number' } + }); + + // Test purchase event with nested objects + const purchase = events.find(e => e.eventName === 'purchase'); + assert.ok(purchase, 'Should detect purchase event'); + assert.ok(purchase.properties.shipping, 'Should include nested shipping property'); + assert.strictEqual(purchase.properties.shipping.type, 'object'); + assert.ok(purchase.properties.shipping.properties.address, 'Should include nested address property'); + + // Test pageView with empty properties + const pageView = events.find(e => e.eventName === 'pageView'); + assert.ok(pageView, 'Should detect pageView event'); + assert.deepStrictEqual(pageView.properties, {}, 'Should handle empty properties object'); + }); + + test('should handle method-as-event alongside standard custom functions', () => { + const methodEventFile = path.join(fixturesDir, 'typescript', 'method-event.ts'); + const program = createProgram(methodEventFile); + const customFunctions = [ + 'eventCalls.EVENT_NAME(PROPERTIES)', + 'customTrackFunction(userId, EVENT_NAME, PROPERTIES)' + ]; + const events = analyzeTsFile(methodEventFile, program, customFunctions.map(parseCustomFunctionSignature)); + + // Should detect method-as-event calls + const viewItemList = events.find(e => e.eventName === 'viewItemList' && e.source === 'custom'); + assert.ok(viewItemList, 'Should detect method-as-event calls'); + + // Should not detect standard custom function calls (none in this file) + const customEvents = events.filter(e => e.source === 'custom'); + assert.ok(customEvents.length >= 5, 'Should detect multiple method-as-event events'); + }); }); diff --git a/tests/cli.test.js b/tests/cli.test.js index 39ba180..adafd8e 100644 --- a/tests/cli.test.js +++ b/tests/cli.test.js @@ -22,6 +22,8 @@ const customFunctionSignatures = [ 'this.props.customTrackFunction6(EVENT_NAME, PROPERTIES)', 'customTrackFunction7(EVENT_NAME, PROPERTIES)', 'getTrackingService().track(EVENT_NAME, PROPERTIES)', + // Method-as-event signature + 'eventCalls.EVENT_NAME(PROPERTIES)', ]; // Helper function to run CLI and capture output diff --git a/tests/fixtures/javascript/method-event.js b/tests/fixtures/javascript/method-event.js new file mode 100644 index 0000000..9404050 --- /dev/null +++ b/tests/fixtures/javascript/method-event.js @@ -0,0 +1,64 @@ +// Method-as-event tracking pattern test fixture +// Uses eventCalls.METHOD_NAME({ properties }) where METHOD_NAME is the event name + +eventCalls.viewItemList({ + items: [{ id: '1', name: 'Product' }], + item_list_id: '/products', + item_list_name: 'Featured Products', +}); + +function handleAddToCart() { + eventCalls.addToCart({ + items: [{ id: '1', price: 29.99, quantity: 2 }], + value: 59.98, + user: { id: 'user123', email: 'test@example.com', name: 'John Doe' }, + }); +} + +eventCalls.removeFromCart({ + items: [{ id: '1' }], + value: 29.99, +}); + +const checkoutHandler = () => { + eventCalls.beginCheckout({ + items: [{ id: '1' }, { id: '2' }], + currency: 'USD', + value: 150.00, + }); +}; + +checkoutHandler(); + +// Test with nested objects +eventCalls.purchase({ + transaction_id: 'txn_123', + value: 99.99, + currency: 'USD', + items: [ + { + item_id: 'sku_001', + item_name: 'Product A', + price: 49.99, + quantity: 1, + }, + { + item_id: 'sku_002', + item_name: 'Product B', + price: 50.00, + quantity: 1, + }, + ], + shipping: { + method: 'standard', + cost: 5.99, + address: { + city: 'San Francisco', + state: 'CA', + zip: '94102', + }, + }, +}); + +// Test with no properties (empty object) +eventCalls.pageView({}); diff --git a/tests/fixtures/javascript/tracking-schema-javascript.yaml b/tests/fixtures/javascript/tracking-schema-javascript.yaml index f1ca93c..3b20ff5 100644 --- a/tests/fixtures/javascript/tracking-schema-javascript.yaml +++ b/tests/fixtures/javascript/tracking-schema-javascript.yaml @@ -11,6 +11,10 @@ events: line: 14 function: trackGA4 destination: googleanalytics + - path: method-event.js + line: 34 + function: global + destination: custom properties: order_id: type: any @@ -25,6 +29,32 @@ events: type: string state: type: string + transaction_id: + type: string + value: + type: number + currency: + type: string + items: + type: array + items: + type: object + shipping: + type: object + properties: + method: + type: string + cost: + type: number + address: + type: object + properties: + city: + type: string + state: + type: string + zip: + type: string newEvent: implementations: - path: main.js @@ -379,3 +409,75 @@ events: type: string baz: type: string + viewItemList: + implementations: + - path: method-event.js + line: 4 + function: global + destination: custom + properties: + items: + type: array + items: + type: object + item_list_id: + type: string + item_list_name: + type: string + addToCart: + implementations: + - path: method-event.js + line: 11 + function: handleAddToCart + destination: custom + properties: + items: + type: array + items: + type: object + value: + type: number + user: + type: object + properties: + id: + type: string + email: + type: string + name: + type: string + removeFromCart: + implementations: + - path: method-event.js + line: 18 + function: global + destination: custom + properties: + items: + type: array + items: + type: object + value: + type: number + beginCheckout: + implementations: + - path: method-event.js + line: 24 + function: checkoutHandler + destination: custom + properties: + items: + type: array + items: + type: object + currency: + type: string + value: + type: number + pageView: + implementations: + - path: method-event.js + line: 64 + function: global + destination: custom + properties: {} diff --git a/tests/fixtures/tracking-schema-all.yaml b/tests/fixtures/tracking-schema-all.yaml index b862789..c8402e9 100644 --- a/tests/fixtures/tracking-schema-all.yaml +++ b/tests/fixtures/tracking-schema-all.yaml @@ -11,6 +11,18 @@ events: line: 14 function: trackGA4 destination: googleanalytics + - path: javascript/method-event.js + line: 34 + function: global + destination: custom + - path: typescript/method-event.ts + line: 34 + function: global + destination: custom + - path: typescript/type-resolution.ts + line: 54 + function: global + destination: custom - path: swift/main.swift line: 73 function: trackGA_Purchase @@ -37,6 +49,43 @@ events: type: string state: type: string + transaction_id: + type: string + value: + type: number + currency: + type: string + items: + type: array + items: + type: object + shipping: + type: object + properties: + method: + type: string + cost: + type: number + address: + type: object + properties: + city: + type: string + state: + type: string + zip: + type: string + orderId: + type: string + user: + type: object + properties: + id: + type: string + name: + type: string + email: + type: string newEvent: implementations: - path: javascript/main.js @@ -1645,6 +1694,240 @@ events: type: string count: type: number + viewItemList: + implementations: + - path: javascript/method-event.js + line: 4 + function: global + destination: custom + - path: typescript/method-event.ts + line: 4 + function: global + destination: custom + properties: + items: + type: array + items: + type: object + item_list_id: + type: string + item_list_name: + type: string + addToCart: + implementations: + - path: javascript/method-event.js + line: 11 + function: handleAddToCart + destination: custom + - path: typescript/method-event.ts + line: 11 + function: handleAddToCart + destination: custom + properties: + items: + type: array + items: + type: object + value: + type: number + user: + type: object + properties: + id: + type: string + email: + type: string + name: + type: string + removeFromCart: + implementations: + - path: javascript/method-event.js + line: 18 + function: global + destination: custom + - path: typescript/method-event.ts + line: 18 + function: global + destination: custom + properties: + items: + type: array + items: + type: object + value: + type: number + beginCheckout: + implementations: + - path: javascript/method-event.js + line: 24 + function: checkoutHandler + destination: custom + - path: typescript/method-event.ts + line: 24 + function: checkoutHandler + destination: custom + properties: + items: + type: array + items: + type: object + currency: + type: string + value: + type: number + pageView: + implementations: + - path: javascript/method-event.js + line: 64 + function: global + destination: custom + - path: typescript/method-event.ts + line: 64 + function: global + destination: custom + properties: {} + complexOperation: + implementations: + - path: typescript/method-event.ts + line: 72 + function: global + destination: custom + properties: + timestamp: + type: number + metadata: + type: object + properties: + source: + type: string + version: + type: string + viewCart: + implementations: + - path: typescript/type-resolution.ts + line: 47 + function: global + destination: custom + properties: + items: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + price: + type: number + quantity: + type: number + sku: + type: string + total: + type: number + subscriptionCreated: + implementations: + - path: typescript/type-resolution.ts + line: 62 + function: global + destination: custom + properties: + userId: + type: string + subscription: + type: enum + values: + - monthly + - quarterly + - yearly + paymentMethod: + type: enum + values: + - credit_card + - debit_card + - paypal + checkoutCompleted: + implementations: + - path: typescript/type-resolution.ts + line: 91 + function: global + destination: custom + properties: + checkout: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + price: + type: number + quantity: + type: number + sku: + type: string + user: + type: object + properties: + id: + type: string + email: + type: string + name: + type: string + subscription: + type: enum + values: + - monthly + - quarterly + - yearly + preferredPayment: + type: enum + values: + - credit_card + - debit_card + - paypal + shippingAddress: + type: object + properties: + street: + type: string + city: + type: string + state: + type: string + zip: + type: string + billingAddress: + type: object + total: + type: number + timestamp: + type: number + userAction: + implementations: + - path: typescript/type-resolution.ts + line: 98 + function: global + destination: custom + properties: + user: + type: object + properties: + id: + type: string + email: + type: string + name: + type: string + action: + type: string ViewedPage: implementations: - path: typescript-alias/app/components/main.ts diff --git a/tests/fixtures/typescript/method-event.ts b/tests/fixtures/typescript/method-event.ts new file mode 100644 index 0000000..28bb21f --- /dev/null +++ b/tests/fixtures/typescript/method-event.ts @@ -0,0 +1,75 @@ +// Method-as-event tracking pattern test fixture +// Uses eventCalls.METHOD_NAME({ properties }) where METHOD_NAME is the event name + +eventCalls.viewItemList({ + items: [{ id: '1', name: 'Product' }], + item_list_id: '/products', + item_list_name: 'Featured Products', +}); + +function handleAddToCart(): void { + eventCalls.addToCart({ + items: [{ id: '1', price: 29.99, quantity: 2 }], + value: 59.98, + user: { id: 'user123', email: 'test@example.com', name: 'John Doe' }, + }); +} + +eventCalls.removeFromCart({ + items: [{ id: '1' }], + value: 29.99, +}); + +const checkoutHandler = (): void => { + eventCalls.beginCheckout({ + items: [{ id: '1' }, { id: '2' }], + currency: 'USD', + value: 150.00, + }); +}; + +checkoutHandler(); + +// Test with nested objects +eventCalls.purchase({ + transaction_id: 'txn_123', + value: 99.99, + currency: 'USD', + items: [ + { + item_id: 'sku_001', + item_name: 'Product A', + price: 49.99, + quantity: 1, + }, + { + item_id: 'sku_002', + item_name: 'Product B', + price: 50.00, + quantity: 1, + }, + ], + shipping: { + method: 'standard', + cost: 5.99, + address: { + city: 'San Francisco', + state: 'CA', + zip: '94102', + }, + }, +}); + +// Test with no properties (empty object) +eventCalls.pageView({}); + +// Test with constant event names (method name is still the event) +const EVENT_TYPES = { + CUSTOM_EVENT: 'custom_event_name', +}; + +// Even with constants defined, method name takes precedence +eventCalls.complexOperation({ + timestamp: Date.now(), + metadata: { source: 'test', version: '1.0' }, +}); diff --git a/tests/fixtures/typescript/tracking-schema-typescript.yaml b/tests/fixtures/typescript/tracking-schema-typescript.yaml index 4242879..7056a16 100644 --- a/tests/fixtures/typescript/tracking-schema-typescript.yaml +++ b/tests/fixtures/typescript/tracking-schema-typescript.yaml @@ -550,3 +550,267 @@ events: type: string count: type: number + viewItemList: + implementations: + - path: method-event.ts + line: 4 + function: global + destination: custom + properties: + items: + type: array + items: + type: object + item_list_id: + type: string + item_list_name: + type: string + addToCart: + implementations: + - path: method-event.ts + line: 11 + function: handleAddToCart + destination: custom + properties: + items: + type: array + items: + type: object + value: + type: number + user: + type: object + properties: + id: + type: string + email: + type: string + name: + type: string + removeFromCart: + implementations: + - path: method-event.ts + line: 18 + function: global + destination: custom + properties: + items: + type: array + items: + type: object + value: + type: number + beginCheckout: + implementations: + - path: method-event.ts + line: 24 + function: checkoutHandler + destination: custom + properties: + items: + type: array + items: + type: object + currency: + type: string + value: + type: number + purchase: + implementations: + - path: method-event.ts + line: 34 + function: global + destination: custom + - path: type-resolution.ts + line: 54 + function: global + destination: custom + properties: + transaction_id: + type: string + value: + type: number + currency: + type: string + items: + type: array + items: + type: object + shipping: + type: object + properties: + method: + type: string + cost: + type: number + address: + type: object + properties: + city: + type: string + state: + type: string + zip: + type: string + orderId: + type: string + user: + type: object + properties: + id: + type: string + name: + type: string + email: + type: string + total: + type: number + pageView: + implementations: + - path: method-event.ts + line: 64 + function: global + destination: custom + properties: {} + complexOperation: + implementations: + - path: method-event.ts + line: 72 + function: global + destination: custom + properties: + timestamp: + type: number + metadata: + type: object + properties: + source: + type: string + version: + type: string + viewCart: + implementations: + - path: type-resolution.ts + line: 47 + function: global + destination: custom + properties: + items: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + price: + type: number + quantity: + type: number + sku: + type: string + total: + type: number + subscriptionCreated: + implementations: + - path: type-resolution.ts + line: 62 + function: global + destination: custom + properties: + userId: + type: string + subscription: + type: enum + values: + - monthly + - quarterly + - yearly + paymentMethod: + type: enum + values: + - credit_card + - debit_card + - paypal + checkoutCompleted: + implementations: + - path: type-resolution.ts + line: 91 + function: global + destination: custom + properties: + checkout: + type: object + properties: + items: + type: array + items: + type: object + properties: + id: + type: string + name: + type: string + price: + type: number + quantity: + type: number + sku: + type: string + user: + type: object + properties: + id: + type: string + email: + type: string + name: + type: string + subscription: + type: enum + values: + - monthly + - quarterly + - yearly + preferredPayment: + type: enum + values: + - credit_card + - debit_card + - paypal + shippingAddress: + type: object + properties: + street: + type: string + city: + type: string + state: + type: string + zip: + type: string + billingAddress: + type: object + total: + type: number + timestamp: + type: number + userAction: + implementations: + - path: type-resolution.ts + line: 98 + function: global + destination: custom + properties: + user: + type: object + properties: + id: + type: string + email: + type: string + name: + type: string + action: + type: string diff --git a/tests/fixtures/typescript/type-resolution.ts b/tests/fixtures/typescript/type-resolution.ts new file mode 100644 index 0000000..d4287d5 --- /dev/null +++ b/tests/fixtures/typescript/type-resolution.ts @@ -0,0 +1,101 @@ +// Test fixture for advanced type resolution scenarios +// This file tests: +// 1. Custom interface types being resolved to their properties +// 2. Union types with undefined (e.g., { id: string } | undefined) +// 3. Enum types being resolved to their values +// 4. Complex nested objects + +// Enum definition +enum SubscriptionType { + MONTHLY = 'monthly', + QUARTERLY = 'quarterly', + YEARLY = 'yearly', +} + +// Another enum for testing +enum PaymentMethod { + CREDIT_CARD = 'credit_card', + DEBIT_CARD = 'debit_card', + PAYPAL = 'paypal', +} + +// Interface definition +interface ICartItem { + id: string; + name: string; + price: number; + quantity: number; + sku?: string; +} + +// Interface with optional and enum fields +interface IUserInfo { + id: string; + email: string; + name: string; + subscription?: SubscriptionType; + preferredPayment?: PaymentMethod; +} + +// Declare eventCalls for testing +declare const eventCalls: { + [key: string]: (props: any) => void; +}; + +// Test 1: Array of custom interface types +const cartItems: ICartItem[] = [{ id: '1', name: 'Product', price: 10, quantity: 1 }]; +eventCalls.viewCart({ + items: cartItems, + total: 100, +}); + +// Test 2: Union type with undefined (object literal in union) +const user: { id: string; name: string; email: string } | undefined = { id: '123', name: 'John', email: 'john@example.com' }; +eventCalls.purchase({ + orderId: 'order_123', + user: user, + total: 150, +}); + +// Test 3: Enum type resolution +const subscriptionType: SubscriptionType = SubscriptionType.MONTHLY; +eventCalls.subscriptionCreated({ + userId: 'user_123', + subscription: subscriptionType, + paymentMethod: PaymentMethod.CREDIT_CARD, +}); + +// Test 4: Complex nested object with interface +interface IAddress { + street: string; + city: string; + state: string; + zip: string; +} + +interface ICheckoutData { + items: ICartItem[]; + user: IUserInfo; + shippingAddress: IAddress; + billingAddress?: IAddress; + total: number; +} + +const checkoutData: ICheckoutData = { + items: [], + user: { id: '1', email: 'test@test.com', name: 'Test' }, + shippingAddress: { street: '123 Main', city: 'SF', state: 'CA', zip: '94102' }, + total: 200, +}; + +eventCalls.checkoutCompleted({ + checkout: checkoutData, + timestamp: Date.now(), +}); + +// Test 5: Inline object with optional fields using conditional expression +const maybeUser = true ? { id: 'user1', email: 'test@example.com', name: 'Test User' } : undefined; +eventCalls.userAction({ + user: maybeUser, + action: 'click', +});