@flisk/analyze-tracking 0.7.6 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -16,7 +16,7 @@ let parse = null;
16
16
  * @returns {Promise<Array>} Array of tracking events found in the file
17
17
  * @throws {Error} If the file cannot be read or parsed
18
18
  */
19
- async function analyzeRubyFile(filePath, customFunction) {
19
+ async function analyzeRubyFile(filePath, customFunctionSignatures = null) {
20
20
  // Lazy load the Ruby Prism parser
21
21
  if (!parse) {
22
22
  const { loadPrism } = await import('@ruby/prism');
@@ -26,21 +26,28 @@ async function analyzeRubyFile(filePath, customFunction) {
26
26
  try {
27
27
  // Read the file content
28
28
  const code = fs.readFileSync(filePath, 'utf8');
29
-
30
- // Parse the Ruby code into an AST
29
+
30
+ // Parse the Ruby code into an AST once
31
31
  let ast;
32
32
  try {
33
33
  ast = await parse(code);
34
34
  } catch (parseError) {
35
35
  console.error(`Error parsing file ${filePath}:`, parseError.message);
36
- return []; // Return empty events array if parsing fails
36
+ return [];
37
37
  }
38
38
 
39
- // Create a visitor and analyze the AST
40
- const visitor = new TrackingVisitor(code, filePath, customFunction);
39
+ // Single visitor pass covering all custom configs
40
+ const visitor = new TrackingVisitor(code, filePath, customFunctionSignatures || []);
41
41
  const events = await visitor.analyze(ast);
42
42
 
43
- return events;
43
+ // Deduplicate events
44
+ const unique = new Map();
45
+ for (const evt of events) {
46
+ const key = `${evt.source}|${evt.eventName}|${evt.line}|${evt.functionName}`;
47
+ if (!unique.has(key)) unique.set(key, evt);
48
+ }
49
+
50
+ return Array.from(unique.values());
44
51
 
45
52
  } catch (fileError) {
46
53
  console.error(`Error reading or processing file ${filePath}:`, fileError.message);
@@ -8,10 +8,10 @@ const { extractEventName, extractProperties } = require('./extractors');
8
8
  const { findWrappingFunction, traverseNode, getLineNumber } = require('./traversal');
9
9
 
10
10
  class TrackingVisitor {
11
- constructor(code, filePath, customFunction = null) {
11
+ constructor(code, filePath, customConfigs = []) {
12
12
  this.code = code;
13
13
  this.filePath = filePath;
14
- this.customFunction = customFunction;
14
+ this.customConfigs = Array.isArray(customConfigs) ? customConfigs : [];
15
15
  this.events = [];
16
16
  }
17
17
 
@@ -22,10 +22,27 @@ class TrackingVisitor {
22
22
  */
23
23
  async processCallNode(node, ancestors) {
24
24
  try {
25
- const source = detectSource(node, this.customFunction);
25
+ let matchedConfig = null;
26
+ let source = null;
27
+
28
+ // Try to match any custom config first
29
+ for (const cfg of this.customConfigs) {
30
+ if (!cfg) continue;
31
+ if (detectSource(node, cfg.functionName) === 'custom') {
32
+ matchedConfig = cfg;
33
+ source = 'custom';
34
+ break;
35
+ }
36
+ }
37
+
38
+ // If no custom match, attempt built-in providers
39
+ if (!source) {
40
+ source = detectSource(node, null);
41
+ }
42
+
26
43
  if (!source) return;
27
44
 
28
- const eventName = extractEventName(node, source);
45
+ const eventName = extractEventName(node, source, matchedConfig);
29
46
  if (!eventName) return;
30
47
 
31
48
  const line = getLineNumber(this.code, node.location);
@@ -33,13 +50,13 @@ class TrackingVisitor {
33
50
  // For module-scoped custom functions, use the custom function name as the functionName
34
51
  // For simple custom functions, use the wrapping function name
35
52
  let functionName;
36
- if (source === 'custom' && this.customFunction && this.customFunction.includes('.')) {
37
- functionName = this.customFunction;
53
+ if (source === 'custom' && matchedConfig && matchedConfig.functionName.includes('.')) {
54
+ functionName = matchedConfig.functionName;
38
55
  } else {
39
56
  functionName = await findWrappingFunction(node, ancestors);
40
57
  }
41
58
 
42
- const properties = await extractProperties(node, source);
59
+ const properties = await extractProperties(node, source, matchedConfig);
43
60
 
44
61
  this.events.push({
45
62
  eventName,
@@ -21,6 +21,7 @@ const EXTRACTION_STRATEGIES = {
21
21
  googleanalytics: extractGoogleAnalyticsEvent,
22
22
  snowplow: extractSnowplowEvent,
23
23
  mparticle: extractMparticleEvent,
24
+ custom: extractCustomEvent,
24
25
  default: extractDefaultEvent
25
26
  };
26
27
 
@@ -30,10 +31,14 @@ const EXTRACTION_STRATEGIES = {
30
31
  * @param {string} source - Analytics provider source
31
32
  * @param {Object} checker - TypeScript type checker
32
33
  * @param {Object} sourceFile - TypeScript source file
34
+ * @param {Object} customConfig - Custom configuration for custom extraction
33
35
  * @returns {EventData} Extracted event data
34
36
  */
35
- function extractEventData(node, source, checker, sourceFile) {
37
+ function extractEventData(node, source, checker, sourceFile, customConfig) {
36
38
  const strategy = EXTRACTION_STRATEGIES[source] || EXTRACTION_STRATEGIES.default;
39
+ if (source === 'custom') {
40
+ return strategy(node, checker, sourceFile, customConfig);
41
+ }
37
42
  return strategy(node, checker, sourceFile);
38
43
  }
39
44
 
@@ -121,6 +126,32 @@ function extractMparticleEvent(node, checker, sourceFile) {
121
126
  return { eventName, propertiesNode };
122
127
  }
123
128
 
129
+ /**
130
+ * Custom extraction
131
+ * @param {Object} node - CallExpression node
132
+ * @param {Object} checker - TypeScript type checker
133
+ * @param {Object} sourceFile - TypeScript source file
134
+ * @param {Object} customConfig - Custom configuration for custom extraction
135
+ * @returns {EventData}
136
+ */
137
+ function extractCustomEvent(node, checker, sourceFile, customConfig) {
138
+ const args = node.arguments || [];
139
+
140
+ const eventArg = args[customConfig?.eventIndex ?? 0];
141
+ const propertiesArg = args[customConfig?.propertiesIndex ?? 1];
142
+
143
+ const eventName = getStringValue(eventArg, checker, sourceFile);
144
+
145
+ const extraArgs = {};
146
+ if (customConfig && customConfig.extraParams) {
147
+ customConfig.extraParams.forEach(extra => {
148
+ extraArgs[extra.name] = args[extra.idx];
149
+ });
150
+ }
151
+
152
+ return { eventName, propertiesNode: propertiesArg, extraArgs };
153
+ }
154
+
124
155
  /**
125
156
  * Default event extraction for standard providers
126
157
  * @param {Object} node - CallExpression node
@@ -149,9 +180,10 @@ function extractDefaultEvent(node, checker, sourceFile) {
149
180
  * @param {string} functionName - Containing function name
150
181
  * @param {Object} checker - TypeScript type checker
151
182
  * @param {Object} sourceFile - TypeScript source file
183
+ * @param {Object} customConfig - Custom configuration for custom extraction
152
184
  * @returns {Object|null} Processed event object or null
153
185
  */
154
- function processEventData(eventData, source, filePath, line, functionName, checker, sourceFile) {
186
+ function processEventData(eventData, source, filePath, line, functionName, checker, sourceFile, customConfig) {
155
187
  const { eventName, propertiesNode } = eventData;
156
188
 
157
189
  if (!eventName || !propertiesNode) {
@@ -184,6 +216,37 @@ function processEventData(eventData, source, filePath, line, functionName, check
184
216
  // Clean up any unresolved type markers
185
217
  const cleanedProperties = cleanupProperties(properties);
186
218
 
219
+ // Handle custom extra params
220
+ if (source === 'custom' && customConfig && eventData.extraArgs) {
221
+ for (const [paramName, argNode] of Object.entries(eventData.extraArgs)) {
222
+ if (argNode && ts.isObjectLiteralExpression(argNode)) {
223
+ // Extract detailed properties from object literal expression
224
+ cleanedProperties[paramName] = {
225
+ type: 'object',
226
+ properties: extractProperties(checker, argNode)
227
+ };
228
+ } else if (argNode && ts.isIdentifier(argNode)) {
229
+ // Handle identifier references to objects
230
+ const resolvedNode = resolveIdentifierToInitializer(checker, argNode, sourceFile);
231
+ if (resolvedNode && ts.isObjectLiteralExpression(resolvedNode)) {
232
+ cleanedProperties[paramName] = {
233
+ type: 'object',
234
+ properties: extractProperties(checker, resolvedNode)
235
+ };
236
+ } else {
237
+ cleanedProperties[paramName] = {
238
+ type: inferNodeValueType(argNode)
239
+ };
240
+ }
241
+ } else {
242
+ // For non-object arguments, use simple type inference
243
+ cleanedProperties[paramName] = {
244
+ type: inferNodeValueType(argNode)
245
+ };
246
+ }
247
+ }
248
+ }
249
+
187
250
  return {
188
251
  eventName,
189
252
  source,
@@ -368,6 +431,16 @@ function cleanupProperties(properties) {
368
431
  return cleaned;
369
432
  }
370
433
 
434
+ function inferNodeValueType(node) {
435
+ if (!node) return 'any';
436
+ if (ts.isStringLiteral(node)) return 'string';
437
+ if (ts.isNumericLiteral(node)) return 'number';
438
+ if (node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword) return 'boolean';
439
+ if (ts.isArrayLiteralExpression(node)) return 'array';
440
+ if (ts.isObjectLiteralExpression(node)) return 'object';
441
+ return 'any';
442
+ }
443
+
371
444
  module.exports = {
372
445
  extractEventData,
373
446
  processEventData
@@ -9,16 +9,14 @@ const { getProgram, findTrackingEvents, ProgramError, SourceFileError } = requir
9
9
  * Analyzes a TypeScript file for analytics tracking calls
10
10
  * @param {string} filePath - Path to the TypeScript file to analyze
11
11
  * @param {Object} [program] - Optional existing TypeScript program to reuse
12
- * @param {string} [customFunction] - Optional custom function name to detect
12
+ * @param {string} [customFunctionSignature] - Optional custom function signature to detect
13
13
  * @returns {Array<Object>} Array of tracking events found in the file
14
14
  */
15
- function analyzeTsFile(filePath, program, customFunction) {
16
- const events = [];
17
-
15
+ function analyzeTsFile(filePath, program = null, customFunctionSignatures = null) {
18
16
  try {
19
- // Get or create TypeScript program
17
+ // Get or create TypeScript program (only once)
20
18
  const tsProgram = getProgram(filePath, program);
21
-
19
+
22
20
  // Get source file from program
23
21
  const sourceFile = tsProgram.getSourceFile(filePath);
24
22
  if (!sourceFile) {
@@ -28,9 +26,17 @@ function analyzeTsFile(filePath, program, customFunction) {
28
26
  // Get type checker
29
27
  const checker = tsProgram.getTypeChecker();
30
28
 
31
- // Find and extract tracking events
32
- const foundEvents = findTrackingEvents(sourceFile, checker, filePath, customFunction);
33
- events.push(...foundEvents);
29
+ // Single-pass collection covering built-in + all custom configs
30
+ const events = findTrackingEvents(sourceFile, checker, filePath, customFunctionSignatures || []);
31
+
32
+ // Deduplicate events
33
+ const unique = new Map();
34
+ for (const evt of events) {
35
+ const key = `${evt.source}|${evt.eventName}|${evt.line}|${evt.functionName}`;
36
+ if (!unique.has(key)) unique.set(key, evt);
37
+ }
38
+
39
+ return Array.from(unique.values());
34
40
 
35
41
  } catch (error) {
36
42
  if (error instanceof ProgramError) {
@@ -42,7 +48,7 @@ function analyzeTsFile(filePath, program, customFunction) {
42
48
  }
43
49
  }
44
50
 
45
- return events;
51
+ return [];
46
52
  }
47
53
 
48
54
  module.exports = { analyzeTsFile };
@@ -65,32 +65,55 @@ function getProgram(filePath, existingProgram) {
65
65
  * @param {Object} sourceFile - TypeScript source file
66
66
  * @param {Object} checker - TypeScript type checker
67
67
  * @param {string} filePath - Path to the file being analyzed
68
- * @param {string} [customFunction] - Custom function name to detect
68
+ * @param {Array<Object>} [customConfigs] - Array of custom function configurations
69
69
  * @returns {Array<Object>} Array of found events
70
70
  */
71
- function findTrackingEvents(sourceFile, checker, filePath, customFunction) {
71
+ function findTrackingEvents(sourceFile, checker, filePath, customConfigs = []) {
72
72
  const events = [];
73
73
 
74
74
  /**
75
- * Visitor function for AST traversal
76
- * @param {Object} node - Current AST node
75
+ * Helper to test if a CallExpression matches a custom function name.
76
+ * We simply rely on node.expression.getText() which preserves the fully qualified name.
77
77
  */
78
+ const matchesCustomFn = (callNode, fnName) => {
79
+ if (!fnName) return false;
80
+ try {
81
+ return callNode.expression && callNode.expression.getText() === fnName;
82
+ } catch {
83
+ return false;
84
+ }
85
+ };
86
+
78
87
  function visit(node) {
79
88
  try {
80
89
  if (ts.isCallExpression(node)) {
81
- const event = extractTrackingEvent(node, sourceFile, checker, filePath, customFunction);
82
- if (event) {
83
- events.push(event);
90
+ let matchedCustom = null;
91
+
92
+ if (Array.isArray(customConfigs) && customConfigs.length > 0) {
93
+ for (const cfg of customConfigs) {
94
+ if (cfg && matchesCustomFn(node, cfg.functionName)) {
95
+ matchedCustom = cfg;
96
+ break;
97
+ }
98
+ }
84
99
  }
100
+
101
+ const event = extractTrackingEvent(
102
+ node,
103
+ sourceFile,
104
+ checker,
105
+ filePath,
106
+ matchedCustom /* may be null */
107
+ );
108
+ if (event) events.push(event);
85
109
  }
86
- // Continue traversing the AST
110
+
87
111
  ts.forEachChild(node, visit);
88
112
  } catch (error) {
89
113
  console.error(`Error processing node in ${filePath}:`, error.message);
90
114
  }
91
115
  }
92
116
 
93
- // Start traversal from the root
94
117
  ts.forEachChild(sourceFile, visit);
95
118
 
96
119
  return events;
@@ -102,25 +125,25 @@ function findTrackingEvents(sourceFile, checker, filePath, customFunction) {
102
125
  * @param {Object} sourceFile - TypeScript source file
103
126
  * @param {Object} checker - TypeScript type checker
104
127
  * @param {string} filePath - File path
105
- * @param {string} [customFunction] - Custom function name
128
+ * @param {Object} [customConfig] - Custom function configuration
106
129
  * @returns {Object|null} Extracted event or null
107
130
  */
108
- function extractTrackingEvent(node, sourceFile, checker, filePath, customFunction) {
131
+ function extractTrackingEvent(node, sourceFile, checker, filePath, customConfig) {
109
132
  // Detect the analytics source
110
- const source = detectAnalyticsSource(node, customFunction);
133
+ const source = detectAnalyticsSource(node, customConfig?.functionName);
111
134
  if (source === 'unknown') {
112
135
  return null;
113
136
  }
114
137
 
115
138
  // Extract event data based on the source
116
- const eventData = extractEventData(node, source, checker, sourceFile);
139
+ const eventData = extractEventData(node, source, checker, sourceFile, customConfig);
117
140
 
118
141
  // Get location and context information
119
142
  const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1;
120
143
  const functionName = findWrappingFunction(node);
121
144
 
122
145
  // Process the event data into final format
123
- return processEventData(eventData, source, filePath, line, functionName, checker, sourceFile);
146
+ return processEventData(eventData, source, filePath, line, functionName, checker, sourceFile, customConfig);
124
147
  }
125
148
 
126
149
  module.exports = {
@@ -0,0 +1,55 @@
1
+ // Create new file with parser implementation
2
+ function parseCustomFunctionSignature(signature) {
3
+ if (!signature || typeof signature !== 'string') {
4
+ return null;
5
+ }
6
+
7
+ // Match function name and optional parameter list
8
+ // Supports names with module prefix like Module.func
9
+ const match = signature.match(/^\s*([A-Za-z0-9_.]+)\s*(?:\(([^)]*)\))?\s*$/);
10
+ if (!match) {
11
+ return null;
12
+ }
13
+
14
+ const functionName = match[1].trim();
15
+ const paramsPart = match[2];
16
+
17
+ // Default legacy behaviour: EVENT_NAME, PROPERTIES
18
+ if (!paramsPart) {
19
+ return {
20
+ functionName,
21
+ eventIndex: 0,
22
+ propertiesIndex: 1,
23
+ extraParams: []
24
+ };
25
+ }
26
+
27
+ // Split params by comma, trimming whitespace
28
+ const params = paramsPart.split(',').map(p => p.trim()).filter(Boolean);
29
+
30
+ const eventIndex = params.findIndex(p => p.toUpperCase() === 'EVENT_NAME');
31
+ let propertiesIndex = params.findIndex(p => p.toUpperCase() === 'PROPERTIES');
32
+
33
+ if (eventIndex === -1) {
34
+ throw new Error('EVENT_NAME is required in custom function signature');
35
+ }
36
+
37
+ if (propertiesIndex === -1) {
38
+ // If PROPERTIES is missing, assume it's at the end of the parameters
39
+ propertiesIndex = params.length;
40
+ }
41
+
42
+ const extraParams = params.map((name, idx) => ({ idx, name }))
43
+ .filter(p => !(p.idx === eventIndex || p.idx === propertiesIndex));
44
+
45
+ return {
46
+ functionName,
47
+ eventIndex,
48
+ propertiesIndex,
49
+ extraParams
50
+ };
51
+ }
52
+
53
+ module.exports = {
54
+ parseCustomFunctionSignature
55
+ };
package/src/index.js CHANGED
@@ -11,8 +11,8 @@ const { generateDescriptions } = require('./generateDescriptions');
11
11
  const { ChatOpenAI } = require('@langchain/openai');
12
12
  const { ChatVertexAI } = require('@langchain/google-vertexai');
13
13
 
14
- async function run(targetDir, outputPath, customFunction, customSourceDetails, generateDescription, provider, model, stdout, format) {
15
- let events = await analyzeDirectory(targetDir, customFunction);
14
+ async function run(targetDir, outputPath, customFunctions, customSourceDetails, generateDescription, provider, model, stdout, format) {
15
+ let events = await analyzeDirectory(targetDir, customFunctions);
16
16
  if (generateDescription) {
17
17
  let llm;
18
18
  if (provider === 'openai') {