diff --git a/.gitignore b/.gitignore index db33a88..73af551 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,6 @@ __pycache__/ # Tracking schema output tracking-schema.yaml + +# Test output +tests/temp* diff --git a/src/analyze/ruby/detectors.js b/src/analyze/ruby/detectors.js index 6c1efe4..7b8dabd 100644 --- a/src/analyze/ruby/detectors.js +++ b/src/analyze/ruby/detectors.js @@ -36,7 +36,18 @@ function detectSource(node, customFunction = null) { if (node.name === 'track_struct_event') return 'snowplow'; // Custom tracking function - if (customFunction && node.name === customFunction) return 'custom'; + if (customFunction) { + // Handle simple function names (e.g., 'customTrackFunction') + if (node.name === customFunction) return 'custom'; + + // Handle module-scoped function names (e.g., 'CustomModule.track') + if (customFunction.includes('.')) { + const [moduleName, methodName] = customFunction.split('.'); + if (node.receiver && node.receiver.name === moduleName && node.name === methodName) { + return 'custom'; + } + } + } return null; } diff --git a/src/analyze/ruby/extractors.js b/src/analyze/ruby/extractors.js index cc17ed2..b86f99a 100644 --- a/src/analyze/ruby/extractors.js +++ b/src/analyze/ruby/extractors.js @@ -14,14 +14,17 @@ const { getValueType } = require('./types'); function extractEventName(node, source) { if (source === 'segment' || source === 'rudderstack') { // Both Segment and Rudderstack use the same format - const params = node.arguments_.arguments_[0].elements; + const params = node.arguments_?.arguments_?.[0]?.elements; + if (!params || !Array.isArray(params)) { + return null; + } const eventProperty = params.find(param => param?.key?.unescaped?.value === 'event'); return eventProperty?.value?.unescaped?.value || null; } if (source === 'mixpanel') { // Mixpanel Ruby SDK format: tracker.track('distinct_id', 'event_name', {...}) - const args = node.arguments_.arguments_; + const args = node.arguments_?.arguments_; if (args && args.length > 1 && args[1]?.unescaped?.value) { return args[1].unescaped.value; } @@ -29,8 +32,8 @@ function extractEventName(node, source) { if (source === 'posthog') { // PostHog Ruby SDK format: posthog.capture({distinct_id: '...', event: '...', properties: {...}}) - const hashArg = node.arguments_.arguments_[0]; - if (hashArg && hashArg.elements) { + const hashArg = node.arguments_?.arguments_?.[0]; + if (hashArg && hashArg.elements && Array.isArray(hashArg.elements)) { const eventProperty = hashArg.elements.find(elem => elem?.key?.unescaped?.value === 'event'); return eventProperty?.value?.unescaped?.value || null; } @@ -38,14 +41,17 @@ function extractEventName(node, source) { if (source === 'snowplow') { // Snowplow Ruby SDK: tracker.track_struct_event(category: '...', action: '...', ...) - const params = node.arguments_.arguments_[0].elements; + const params = node.arguments_?.arguments_?.[0]?.elements; + if (!params || !Array.isArray(params)) { + return null; + } const actionProperty = params.find(param => param?.key?.unescaped?.value === 'action'); return actionProperty?.value?.unescaped?.value || null; } if (source === 'custom') { // Custom function format: customFunction('event_name', {...}) - const args = node.arguments_.arguments_; + const args = node.arguments_?.arguments_; if (args && args.length > 0 && args[0]?.unescaped?.value) { return args[0].unescaped.value; } @@ -65,7 +71,10 @@ async function extractProperties(node, source) { if (source === 'segment' || source === 'rudderstack') { // Both Segment and Rudderstack use the same format - const params = node.arguments_.arguments_[0].elements; + const params = node.arguments_?.arguments_?.[0]?.elements; + if (!params || !Array.isArray(params)) { + return null; + } const properties = {}; // Process all top-level fields except 'event' @@ -108,7 +117,7 @@ async function extractProperties(node, source) { if (source === 'mixpanel') { // Mixpanel Ruby SDK: tracker.track('distinct_id', 'event_name', {properties}) - const args = node.arguments_.arguments_; + const args = node.arguments_?.arguments_; const properties = {}; // Add distinct_id as property (even if it's a variable) @@ -129,10 +138,10 @@ async function extractProperties(node, source) { if (source === 'posthog') { // PostHog Ruby SDK: posthog.capture({distinct_id: '...', event: '...', properties: {...}}) - const hashArg = node.arguments_.arguments_[0]; + const hashArg = node.arguments_?.arguments_?.[0]; const properties = {}; - if (hashArg && hashArg.elements) { + if (hashArg && hashArg.elements && Array.isArray(hashArg.elements)) { // Extract distinct_id if present const distinctIdProperty = hashArg.elements.find(elem => elem?.key?.unescaped?.value === 'distinct_id'); if (distinctIdProperty?.value) { @@ -154,7 +163,10 @@ async function extractProperties(node, source) { if (source === 'snowplow') { // Snowplow Ruby SDK: tracker.track_struct_event(category: '...', action: '...', ...) - const params = node.arguments_.arguments_[0].elements; + const params = node.arguments_?.arguments_?.[0]?.elements; + if (!params || !Array.isArray(params)) { + return null; + } const properties = {}; // Extract all struct event parameters except 'action' (which is used as the event name) @@ -172,7 +184,7 @@ async function extractProperties(node, source) { if (source === 'custom') { // Custom function format: customFunction('event_name', {properties}) - const args = node.arguments_.arguments_; + const args = node.arguments_?.arguments_; if (args && args.length > 1 && args[1] instanceof HashNode) { return await extractHashProperties(args[1]); } diff --git a/src/analyze/ruby/visitor.js b/src/analyze/ruby/visitor.js index 85e8b2d..75c5c95 100644 --- a/src/analyze/ruby/visitor.js +++ b/src/analyze/ruby/visitor.js @@ -29,7 +29,16 @@ class TrackingVisitor { if (!eventName) return; const line = getLineNumber(this.code, node.location); - const functionName = await findWrappingFunction(node, ancestors); + + // For module-scoped custom functions, use the custom function name as the functionName + // For simple custom functions, use the wrapping function name + let functionName; + if (source === 'custom' && this.customFunction && this.customFunction.includes('.')) { + functionName = this.customFunction; + } else { + functionName = await findWrappingFunction(node, ancestors); + } + const properties = await extractProperties(node, source); this.events.push({ diff --git a/src/analyze/typescript/detectors/analytics-source.js b/src/analyze/typescript/detectors/analytics-source.js index 584bd74..2ba6b73 100644 --- a/src/analyze/typescript/detectors/analytics-source.js +++ b/src/analyze/typescript/detectors/analytics-source.js @@ -84,8 +84,8 @@ function detectMemberBasedProvider(node) { return 'unknown'; } - const objectName = node.expression.expression.escapedText; - const methodName = node.expression.name.escapedText; + const objectName = node.expression.expression?.escapedText; + const methodName = node.expression.name?.escapedText; if (!objectName || !methodName) { return 'unknown'; diff --git a/src/analyze/typescript/extractors/property-extractor.js b/src/analyze/typescript/extractors/property-extractor.js index af6e7a0..5420217 100644 --- a/src/analyze/typescript/extractors/property-extractor.js +++ b/src/analyze/typescript/extractors/property-extractor.js @@ -350,6 +350,7 @@ function resolveTypeSchema(checker, typeString) { * @returns {string|null} Literal type or null */ function getLiteralType(node) { + if (!node) return null; if (ts.isStringLiteral(node)) return 'string'; if (ts.isNumericLiteral(node)) return 'number'; if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) return 'boolean'; diff --git a/src/analyze/typescript/utils/function-finder.js b/src/analyze/typescript/utils/function-finder.js index e8428eb..00a9f3a 100644 --- a/src/analyze/typescript/utils/function-finder.js +++ b/src/analyze/typescript/utils/function-finder.js @@ -93,7 +93,7 @@ function findParentFunctionName(node) { parent.initializer && ts.isCallExpression(parent.initializer) && ts.isIdentifier(parent.initializer.expression) && - REACT_HOOKS.has(parent.initializer.expression.escapedText) + isReactHookCall(parent.initializer) ) { return `${parent.initializer.expression.escapedText}(${parent.name.escapedText})`; } diff --git a/tests/analyzeRuby.test.js b/tests/analyzeRuby.test.js index 509f745..6829f39 100644 --- a/tests/analyzeRuby.test.js +++ b/tests/analyzeRuby.test.js @@ -71,7 +71,7 @@ test.describe('analyzeRubyFile', () => { assert.ok(snowplowEvent); assert.strictEqual(snowplowEvent.source, 'snowplow'); assert.strictEqual(snowplowEvent.functionName, 'snowplow_track'); - assert.strictEqual(snowplowEvent.line, 96); + assert.strictEqual(snowplowEvent.line, 109); assert.deepStrictEqual(snowplowEvent.properties, { category: { type: 'string' }, label: { type: 'string' }, @@ -103,7 +103,7 @@ test.describe('analyzeRubyFile', () => { assert.ok(moduleEvent); assert.strictEqual(moduleEvent.source, 'segment'); assert.strictEqual(moduleEvent.functionName, 'track_something'); - assert.strictEqual(moduleEvent.line, 108); + assert.strictEqual(moduleEvent.line, 121); assert.deepStrictEqual(moduleEvent.properties, { anonymous_id: { type: 'string' }, from_module: { type: 'boolean' } @@ -195,6 +195,29 @@ test.describe('analyzeRubyFile', () => { ]); }); + test('should detect custom functions that are methods of a module', async () => { + const customFunction = 'CustomModule.track'; + const events = await analyzeRubyFile(testFilePath, customFunction); + + // Should find the CustomModule.track call + const customModuleEvent = events.find(e => e.source === 'custom' && e.functionName === 'CustomModule.track'); + assert.ok(customModuleEvent); + assert.strictEqual(customModuleEvent.eventName, 'custom_event'); + assert.strictEqual(customModuleEvent.line, 98); + assert.deepStrictEqual(customModuleEvent.properties, { + key: { type: 'string' }, + nested: { + type: 'object', + properties: { + a: { + type: 'array', + items: { type: 'number' } + } + } + } + }); + }); + test('should correctly differentiate between Segment and Rudderstack', async () => { const events = await analyzeRubyFile(testFilePath, null); diff --git a/tests/fixtures/ruby/main.rb b/tests/fixtures/ruby/main.rb index 4c9f7e4..f4a4163 100644 --- a/tests/fixtures/ruby/main.rb +++ b/tests/fixtures/ruby/main.rb @@ -87,6 +87,19 @@ def custom_track_event def customTrackFunction(event_name, params = {}) puts "Custom track: #{event_name} - #{params}" end + + module CustomModule + def track(event_name, params = {}) + # Mock implementation + end + end + + def custom_track_module + CustomModule.track('custom_event', { + key: 'value', + nested: { a: [1, 2, 3] } + }) + end end # Snowplow tracking example diff --git a/tests/fixtures/ruby/tracking-schema-ruby.yaml b/tests/fixtures/ruby/tracking-schema-ruby.yaml index 9cb939f..7f427f1 100644 --- a/tests/fixtures/ruby/tracking-schema-ruby.yaml +++ b/tests/fixtures/ruby/tracking-schema-ruby.yaml @@ -80,7 +80,7 @@ events: add-to-basket: implementations: - path: main.rb - line: 96 + line: 109 function: snowplow_track destination: snowplow properties: @@ -95,7 +95,7 @@ events: Module Event: implementations: - path: main.rb - line: 108 + line: 121 function: track_something destination: segment properties: diff --git a/tests/fixtures/tracking-schema-all.yaml b/tests/fixtures/tracking-schema-all.yaml index bbd70f6..ab82f1b 100644 --- a/tests/fixtures/tracking-schema-all.yaml +++ b/tests/fixtures/tracking-schema-all.yaml @@ -96,7 +96,7 @@ events: function: snowplow_track_events destination: snowplow - path: ruby/main.rb - line: 96 + line: 109 function: snowplow_track destination: snowplow properties: @@ -430,7 +430,7 @@ events: Module Event: implementations: - path: ruby/main.rb - line: 108 + line: 121 function: track_something destination: segment properties: