@flisk/analyze-tracking 0.8.0 → 0.8.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flisk/analyze-tracking",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "Analyzes tracking code in a project and generates data schemas",
5
5
  "main": "src/index.js",
6
6
  "bin": {
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const path = require('path');
7
- const ts = require('typescript');
7
+
8
8
  const { parseCustomFunctionSignature } = require('./utils/customFunctionParser');
9
9
  const { getAllFiles } = require('../utils/fileProcessor');
10
10
  const { analyzeJsFile } = require('./javascript');
@@ -19,11 +19,6 @@ async function analyzeDirectory(dirPath, customFunctions) {
19
19
  const customFunctionSignatures = (customFunctions && customFunctions?.length > 0) ? customFunctions.map(parseCustomFunctionSignature) : null;
20
20
 
21
21
  const files = getAllFiles(dirPath);
22
- const tsFiles = files.filter(file => /\.(tsx?)$/.test(file));
23
- const tsProgram = ts.createProgram(tsFiles, {
24
- target: ts.ScriptTarget.ESNext,
25
- module: ts.ModuleKind.CommonJS,
26
- });
27
22
 
28
23
  for (const file of files) {
29
24
  let events = [];
@@ -37,7 +32,8 @@ async function analyzeDirectory(dirPath, customFunctions) {
37
32
  if (isJsFile) {
38
33
  events = analyzeJsFile(file, customFunctionSignatures);
39
34
  } else if (isTsFile) {
40
- events = analyzeTsFile(file, tsProgram, customFunctionSignatures);
35
+ // Pass null program so analyzeTsFile will create a per-file program using the file's nearest tsconfig.json
36
+ events = analyzeTsFile(file, null, customFunctionSignatures);
41
37
  } else if (isPythonFile) {
42
38
  events = await analyzePythonFile(file, customFunctionSignatures);
43
39
  } else if (isRubyFile) {
@@ -28,29 +28,31 @@ const EXTRACTION_STRATEGIES = {
28
28
  * Extracts event information from a CallExpression node
29
29
  * @param {Object} node - AST CallExpression node
30
30
  * @param {string} source - Analytics provider source
31
+ * @param {Object} constantMap - Collected constant map
31
32
  * @param {Object} customConfig - Parsed custom function configuration
32
33
  * @returns {EventData} Extracted event data
33
34
  */
34
- function extractEventData(node, source, customConfig) {
35
+ function extractEventData(node, source, constantMap = {}, customConfig) {
35
36
  const strategy = EXTRACTION_STRATEGIES[source] || EXTRACTION_STRATEGIES.default;
36
37
  if (source === 'custom') {
37
- return strategy(node, customConfig);
38
+ return strategy(node, constantMap, customConfig);
38
39
  }
39
- return strategy(node);
40
+ return strategy(node, constantMap);
40
41
  }
41
42
 
42
43
  /**
43
44
  * Extracts Google Analytics event data
44
45
  * @param {Object} node - CallExpression node
46
+ * @param {Object} constantMap - Collected constant map
45
47
  * @returns {EventData}
46
48
  */
47
- function extractGoogleAnalyticsEvent(node) {
49
+ function extractGoogleAnalyticsEvent(node, constantMap) {
48
50
  if (!node.arguments || node.arguments.length < 3) {
49
51
  return { eventName: null, propertiesNode: null };
50
52
  }
51
53
 
52
54
  // gtag('event', 'event_name', { properties })
53
- const eventName = getStringValue(node.arguments[1]);
55
+ const eventName = getStringValue(node.arguments[1], constantMap);
54
56
  const propertiesNode = node.arguments[2];
55
57
 
56
58
  return { eventName, propertiesNode };
@@ -59,9 +61,10 @@ function extractGoogleAnalyticsEvent(node) {
59
61
  /**
60
62
  * Extracts Snowplow event data
61
63
  * @param {Object} node - CallExpression node
64
+ * @param {Object} constantMap - Collected constant map
62
65
  * @returns {EventData}
63
66
  */
64
- function extractSnowplowEvent(node) {
67
+ function extractSnowplowEvent(node, constantMap) {
65
68
  if (!node.arguments || node.arguments.length === 0) {
66
69
  return { eventName: null, propertiesNode: null };
67
70
  }
@@ -75,7 +78,7 @@ function extractSnowplowEvent(node) {
75
78
 
76
79
  if (structEventArg.type === NODE_TYPES.OBJECT_EXPRESSION) {
77
80
  const actionProperty = findPropertyByKey(structEventArg, 'action');
78
- const eventName = actionProperty ? getStringValue(actionProperty.value) : null;
81
+ const eventName = actionProperty ? getStringValue(actionProperty.value, constantMap) : null;
79
82
 
80
83
  return { eventName, propertiesNode: structEventArg };
81
84
  }
@@ -87,15 +90,16 @@ function extractSnowplowEvent(node) {
87
90
  /**
88
91
  * Extracts mParticle event data
89
92
  * @param {Object} node - CallExpression node
93
+ * @param {Object} constantMap - Collected constant map
90
94
  * @returns {EventData}
91
95
  */
92
- function extractMparticleEvent(node) {
96
+ function extractMparticleEvent(node, constantMap) {
93
97
  if (!node.arguments || node.arguments.length < 3) {
94
98
  return { eventName: null, propertiesNode: null };
95
99
  }
96
100
 
97
101
  // mParticle.logEvent('event_name', mParticle.EventType.Navigation, { properties })
98
- const eventName = getStringValue(node.arguments[0]);
102
+ const eventName = getStringValue(node.arguments[0], constantMap);
99
103
  const propertiesNode = node.arguments[2];
100
104
 
101
105
  return { eventName, propertiesNode };
@@ -104,15 +108,16 @@ function extractMparticleEvent(node) {
104
108
  /**
105
109
  * Default event extraction for standard providers
106
110
  * @param {Object} node - CallExpression node
111
+ * @param {Object} constantMap - Collected constant map
107
112
  * @returns {EventData}
108
113
  */
109
- function extractDefaultEvent(node) {
114
+ function extractDefaultEvent(node, constantMap) {
110
115
  if (!node.arguments || node.arguments.length < 2) {
111
116
  return { eventName: null, propertiesNode: null };
112
117
  }
113
118
 
114
119
  // provider.track('event_name', { properties })
115
- const eventName = getStringValue(node.arguments[0]);
120
+ const eventName = getStringValue(node.arguments[0], constantMap);
116
121
  const propertiesNode = node.arguments[1];
117
122
 
118
123
  return { eventName, propertiesNode };
@@ -121,16 +126,17 @@ function extractDefaultEvent(node) {
121
126
  /**
122
127
  * Extracts Custom function event data according to signature
123
128
  * @param {Object} node - CallExpression node
129
+ * @param {Object} constantMap - Collected constant map
124
130
  * @param {Object} customConfig - Parsed custom function configuration
125
131
  * @returns {EventData & {extraArgs:Object}} event data plus extra args map
126
132
  */
127
- function extractCustomEvent(node, customConfig) {
133
+ function extractCustomEvent(node, constantMap, customConfig) {
128
134
  const args = node.arguments || [];
129
135
 
130
136
  const eventArg = args[customConfig?.eventIndex ?? 0];
131
137
  const propertiesArg = args[customConfig?.propertiesIndex ?? 1];
132
138
 
133
- const eventName = getStringValue(eventArg);
139
+ const eventName = getStringValue(eventArg, constantMap);
134
140
 
135
141
  const extraArgs = {};
136
142
  if (customConfig && customConfig.extraParams) {
@@ -197,13 +203,17 @@ function processEventData(eventData, source, filePath, line, functionName, custo
197
203
  /**
198
204
  * Gets string value from an AST node
199
205
  * @param {Object} node - AST node
206
+ * @param {Object} constantMap - Collected constant map
200
207
  * @returns {string|null} String value or null
201
208
  */
202
- function getStringValue(node) {
209
+ function getStringValue(node, constantMap = {}) {
203
210
  if (!node) return null;
204
211
  if (node.type === NODE_TYPES.LITERAL && typeof node.value === 'string') {
205
212
  return node.value;
206
213
  }
214
+ if (node.type === NODE_TYPES.MEMBER_EXPRESSION) {
215
+ return resolveMemberExpressionToString(node, constantMap);
216
+ }
207
217
  return null;
208
218
  }
209
219
 
@@ -240,6 +250,26 @@ function inferNodeValueType(node) {
240
250
  }
241
251
  }
242
252
 
253
+ // Helper to resolve MemberExpression (CONST.KEY) to string using collected constant map
254
+ function resolveMemberExpressionToString(node, constantMap) {
255
+ if (!node || node.type !== NODE_TYPES.MEMBER_EXPRESSION) return null;
256
+ if (node.computed) return null; // Only support dot notation
257
+
258
+ const object = node.object;
259
+ const property = node.property;
260
+
261
+ if (object.type !== NODE_TYPES.IDENTIFIER) return null;
262
+ if (property.type !== NODE_TYPES.IDENTIFIER) return null;
263
+
264
+ const objName = object.name;
265
+ const propName = property.name;
266
+
267
+ if (constantMap && constantMap[objName] && typeof constantMap[objName][propName] === 'string') {
268
+ return constantMap[objName][propName];
269
+ }
270
+ return null;
271
+ }
272
+
243
273
  module.exports = {
244
274
  extractEventData,
245
275
  processEventData
@@ -120,6 +120,57 @@ function nodeMatchesCustomFunction(node, fnName) {
120
120
  return false;
121
121
  }
122
122
 
123
+ // -----------------------------------------------------------------------------
124
+ // Utility – collect constants defined as plain objects or Object.freeze({...})
125
+ // -----------------------------------------------------------------------------
126
+ function collectConstantStringMap(ast) {
127
+ const map = {};
128
+
129
+ walk.simple(ast, {
130
+ VariableDeclaration(node) {
131
+ // Only consider const declarations
132
+ if (node.kind !== 'const') return;
133
+ node.declarations.forEach(decl => {
134
+ if (decl.id.type !== NODE_TYPES.IDENTIFIER || !decl.init) return;
135
+ const name = decl.id.name;
136
+ let objLiteral = null;
137
+
138
+ if (decl.init.type === NODE_TYPES.OBJECT_EXPRESSION) {
139
+ objLiteral = decl.init;
140
+ } else if (decl.init.type === NODE_TYPES.CALL_EXPRESSION) {
141
+ // Check for Object.freeze({...})
142
+ const callee = decl.init.callee;
143
+ if (
144
+ callee &&
145
+ callee.type === NODE_TYPES.MEMBER_EXPRESSION &&
146
+ callee.object.type === NODE_TYPES.IDENTIFIER &&
147
+ callee.object.name === 'Object' &&
148
+ callee.property.type === NODE_TYPES.IDENTIFIER &&
149
+ callee.property.name === 'freeze' &&
150
+ decl.init.arguments.length > 0 &&
151
+ decl.init.arguments[0].type === NODE_TYPES.OBJECT_EXPRESSION
152
+ ) {
153
+ objLiteral = decl.init.arguments[0];
154
+ }
155
+ }
156
+
157
+ if (objLiteral) {
158
+ map[name] = {};
159
+ objLiteral.properties.forEach(prop => {
160
+ if (!prop.key || !prop.value) return;
161
+ const keyName = prop.key.name || prop.key.value;
162
+ if (prop.value.type === NODE_TYPES.LITERAL && typeof prop.value.value === 'string') {
163
+ map[name][keyName] = prop.value.value;
164
+ }
165
+ });
166
+ }
167
+ });
168
+ }
169
+ });
170
+
171
+ return map;
172
+ }
173
+
123
174
  /**
124
175
  * Walk the AST once and find tracking events for built-in providers plus any number of custom
125
176
  * function configurations. This avoids the previous O(n * customConfigs) behaviour.
@@ -132,6 +183,9 @@ function nodeMatchesCustomFunction(node, fnName) {
132
183
  function findTrackingEvents(ast, filePath, customConfigs = []) {
133
184
  const events = [];
134
185
 
186
+ // Collect constant mappings once per file
187
+ const constantMap = collectConstantStringMap(ast);
188
+
135
189
  walk.ancestor(ast, {
136
190
  [NODE_TYPES.CALL_EXPRESSION]: (node, ancestors) => {
137
191
  try {
@@ -148,12 +202,10 @@ function findTrackingEvents(ast, filePath, customConfigs = []) {
148
202
  }
149
203
 
150
204
  if (matchedCustomConfig) {
151
- // Force source to 'custom' and use matched config
152
- const event = extractTrackingEvent(node, ancestors, filePath, matchedCustomConfig);
205
+ const event = extractTrackingEvent(node, ancestors, filePath, constantMap, matchedCustomConfig);
153
206
  if (event) events.push(event);
154
207
  } else {
155
- // Let built-in detector figure out source (pass undefined customFunction)
156
- const event = extractTrackingEvent(node, ancestors, filePath, null);
208
+ const event = extractTrackingEvent(node, ancestors, filePath, constantMap, null);
157
209
  if (event) events.push(event);
158
210
  }
159
211
  } catch (error) {
@@ -170,24 +222,18 @@ function findTrackingEvents(ast, filePath, customConfigs = []) {
170
222
  * @param {Object} node - CallExpression node
171
223
  * @param {Array<Object>} ancestors - Ancestor nodes
172
224
  * @param {string} filePath - File path
225
+ * @param {Object} constantMap - Constant string map
173
226
  * @param {Object} [customConfig] - Custom function configuration object
174
227
  * @returns {Object|null} Extracted event or null
175
228
  */
176
- function extractTrackingEvent(node, ancestors, filePath, customConfig) {
177
- // Detect the analytics source
229
+ function extractTrackingEvent(node, ancestors, filePath, constantMap, customConfig) {
178
230
  const source = detectAnalyticsSource(node, customConfig?.functionName);
179
231
  if (source === 'unknown') {
180
232
  return null;
181
233
  }
182
-
183
- // Extract event data based on the source
184
- const eventData = extractEventData(node, source, customConfig);
185
-
186
- // Get location and context information
234
+ const eventData = extractEventData(node, source, constantMap, customConfig);
187
235
  const line = node.loc.start.line;
188
236
  const functionName = findWrappingFunction(node, ancestors);
189
-
190
- // Process the event data into final format
191
237
  return processEventData(eventData, source, filePath, line, functionName, customConfig);
192
238
  }
193
239
 
@@ -4,15 +4,17 @@
4
4
  */
5
5
 
6
6
  const { getValueType } = require('./types');
7
+ const prismPromise = import('@ruby/prism');
7
8
 
8
9
  /**
9
10
  * Extracts the event name from a tracking call based on the source
10
11
  * @param {Object} node - The AST CallNode
11
12
  * @param {string} source - The detected analytics source
12
13
  * @param {Object} customConfig - Custom configuration for custom functions
14
+ * @param {Object} constantMap - Map of constants to resolve constant paths
13
15
  * @returns {string|null} - The extracted event name or null
14
16
  */
15
- function extractEventName(node, source, customConfig = null) {
17
+ async function extractEventName(node, source, customConfig = null, constantMap = {}) {
16
18
  if (source === 'segment' || source === 'rudderstack') {
17
19
  // Both Segment and Rudderstack use the same format
18
20
  const params = node.arguments_?.arguments_?.[0]?.elements;
@@ -62,8 +64,33 @@ function extractEventName(node, source, customConfig = null) {
62
64
  }
63
65
 
64
66
  const eventArg = args[customConfig.eventIndex];
65
- if (eventArg?.unescaped?.value) {
66
- return eventArg.unescaped.value;
67
+ if (eventArg) {
68
+ // String literal
69
+ if (eventArg.unescaped?.value) {
70
+ return eventArg.unescaped.value;
71
+ }
72
+
73
+ // Constant references
74
+ const { ConstantReadNode, ConstantPathNode } = await prismPromise;
75
+ const buildConstPath = (n) => {
76
+ if (!n) return '';
77
+ if (n instanceof ConstantReadNode) return n.name;
78
+ if (n instanceof ConstantPathNode) {
79
+ const parent = buildConstPath(n.parent);
80
+ return parent ? `${parent}::${n.name}` : n.name;
81
+ }
82
+ return '';
83
+ };
84
+
85
+ if (eventArg instanceof ConstantReadNode) {
86
+ const name = eventArg.name;
87
+ // Try to resolve with current constant map, else return name
88
+ return constantMap[name] || name;
89
+ }
90
+ if (eventArg instanceof ConstantPathNode) {
91
+ const pathStr = buildConstPath(eventArg);
92
+ return constantMap[pathStr] || pathStr;
93
+ }
67
94
  }
68
95
  return null;
69
96
  }
@@ -79,7 +106,7 @@ function extractEventName(node, source, customConfig = null) {
79
106
  * @returns {Object|null} - The extracted properties or null
80
107
  */
81
108
  async function extractProperties(node, source, customConfig = null) {
82
- const { HashNode, ArrayNode } = await import('@ruby/prism');
109
+ const { HashNode, ArrayNode } = await prismPromise;
83
110
 
84
111
  if (source === 'segment' || source === 'rudderstack') {
85
112
  // Both Segment and Rudderstack use the same format
@@ -235,7 +262,7 @@ async function extractProperties(node, source, customConfig = null) {
235
262
  * @returns {Object} - The extracted properties
236
263
  */
237
264
  async function extractHashProperties(hashNode) {
238
- const { AssocNode, HashNode, ArrayNode } = await import('@ruby/prism');
265
+ const { AssocNode, HashNode, ArrayNode } = await prismPromise;
239
266
  const properties = {};
240
267
 
241
268
  for (const element of hashNode.elements) {
@@ -278,7 +305,7 @@ async function extractHashProperties(hashNode) {
278
305
  * @returns {Object} - Type information for array items
279
306
  */
280
307
  async function extractArrayItemProperties(arrayNode) {
281
- const { HashNode } = await import('@ruby/prism');
308
+ const { HashNode } = await prismPromise;
282
309
 
283
310
  if (arrayNode.elements.length === 0) {
284
311
  return { type: 'any' };
@@ -4,11 +4,162 @@
4
4
  */
5
5
 
6
6
  const fs = require('fs');
7
+ const path = require('path');
7
8
  const TrackingVisitor = require('./visitor');
8
9
 
9
10
  // Lazy-loaded parse function from Ruby Prism
10
11
  let parse = null;
11
12
 
13
+ /**
14
+ * Extracts the string literal value from an AST node, ignoring a trailing `.freeze` call.
15
+ * Supports:
16
+ * - StringNode
17
+ * - CallNode with receiver StringNode and method `freeze`
18
+ *
19
+ * @param {import('@ruby/prism').PrismNode} node
20
+ * @returns {string|null}
21
+ */
22
+ async function extractStringLiteral(node) {
23
+ if (!node) return null;
24
+
25
+ const {
26
+ StringNode,
27
+ CallNode
28
+ } = await import('@ruby/prism');
29
+
30
+ if (node instanceof StringNode) {
31
+ return node.unescaped?.value ?? null;
32
+ }
33
+
34
+ // Handle "_Section".freeze pattern
35
+ if (node instanceof CallNode && node.name === 'freeze' && node.receiver) {
36
+ return extractStringLiteral(node.receiver);
37
+ }
38
+
39
+ return null;
40
+ }
41
+
42
+ /**
43
+ * Recursively traverses an AST to collect constant assignments and build a map
44
+ * of fully-qualified constant names (e.g. "TelemetryHelper::FINISHED_SECTION")
45
+ * to their string literal values.
46
+ *
47
+ * @param {import('@ruby/prism').PrismNode} node - current AST node
48
+ * @param {string[]} namespaceStack - stack of module/class names
49
+ * @param {Object} constantMap - accumulator map of constant path -> string value
50
+ */
51
+ async function collectConstants(node, namespaceStack, constantMap) {
52
+ if (!node) return;
53
+
54
+ const {
55
+ ModuleNode,
56
+ ClassNode,
57
+ StatementsNode,
58
+ ConstantWriteNode,
59
+ ConstantPathWriteNode,
60
+ ConstantPathNode
61
+ } = await import('@ruby/prism');
62
+
63
+ // Helper to build constant path from ConstantPathNode
64
+ const buildConstPath = (pathNode) => {
65
+ if (!pathNode) return '';
66
+ if (pathNode.type === 'ConstantReadNode') {
67
+ return pathNode.name;
68
+ }
69
+ if (pathNode.type === 'ConstantPathNode') {
70
+ const parent = buildConstPath(pathNode.parent);
71
+ return parent ? `${parent}::${pathNode.name}` : pathNode.name;
72
+ }
73
+ return '';
74
+ };
75
+
76
+ // Process constant assignments
77
+ if (node instanceof ConstantWriteNode) {
78
+ const fullName = [...namespaceStack, node.name].join('::');
79
+ const literal = await extractStringLiteral(node.value);
80
+ if (literal !== null) {
81
+ constantMap[fullName] = literal;
82
+ }
83
+ } else if (node instanceof ConstantPathWriteNode) {
84
+ const fullName = buildConstPath(node.target);
85
+ const literal = await extractStringLiteral(node.value);
86
+ if (fullName && literal !== null) {
87
+ constantMap[fullName] = literal;
88
+ }
89
+ }
90
+
91
+ // Recurse into children depending on node type
92
+ if (node instanceof ModuleNode || node instanceof ClassNode) {
93
+ // Enter namespace
94
+ const name = node.constantPath?.name || node.name; // ModuleNode has constantPath
95
+ const childNamespaceStack = name ? [...namespaceStack, name] : namespaceStack;
96
+
97
+ if (node.body) {
98
+ await collectConstants(node.body, childNamespaceStack, constantMap);
99
+ }
100
+ return;
101
+ }
102
+
103
+ // Generic traversal for other nodes
104
+ if (node instanceof StatementsNode) {
105
+ for (const child of node.body) {
106
+ await collectConstants(child, namespaceStack, constantMap);
107
+ }
108
+ return;
109
+ }
110
+
111
+ // Fallback: iterate over enumerable properties to find nested nodes
112
+ for (const key of Object.keys(node)) {
113
+ const val = node[key];
114
+ if (!val) continue;
115
+
116
+ const traverseChild = async (child) => {
117
+ if (child && typeof child === 'object' && (child.location || child.constructor?.name?.endsWith('Node'))) {
118
+ await collectConstants(child, namespaceStack, constantMap);
119
+ }
120
+ };
121
+
122
+ if (Array.isArray(val)) {
123
+ for (const c of val) {
124
+ await traverseChild(c);
125
+ }
126
+ } else {
127
+ await traverseChild(val);
128
+ }
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Builds a map of constant names to their literal string values for all .rb
134
+ * files in the given directory. This is a best-effort resolver intended for
135
+ * test fixtures and small projects and is not a fully-fledged Ruby constant
136
+ * resolver.
137
+ *
138
+ * @param {string} directory
139
+ * @returns {Promise<Object<string,string>>}
140
+ */
141
+ async function buildConstantMapForDirectory(directory) {
142
+ const constantMap = {};
143
+
144
+ if (!fs.existsSync(directory)) return constantMap;
145
+
146
+ const files = fs.readdirSync(directory).filter(f => f.endsWith('.rb'));
147
+
148
+ for (const file of files) {
149
+ const fullPath = path.join(directory, file);
150
+ try {
151
+ const content = fs.readFileSync(fullPath, 'utf8');
152
+ const ast = await parse(content);
153
+ await collectConstants(ast.value, [], constantMap);
154
+ } catch (err) {
155
+ // Ignore parse errors for unrelated files
156
+ continue;
157
+ }
158
+ }
159
+
160
+ return constantMap;
161
+ }
162
+
12
163
  /**
13
164
  * Analyzes a Ruby file for analytics tracking calls
14
165
  * @param {string} filePath - Path to the Ruby file to analyze
@@ -27,6 +178,10 @@ async function analyzeRubyFile(filePath, customFunctionSignatures = null) {
27
178
  // Read the file content
28
179
  const code = fs.readFileSync(filePath, 'utf8');
29
180
 
181
+ // Build constant map for current directory (sibling .rb files)
182
+ const currentDir = path.dirname(filePath);
183
+ const constantMap = await buildConstantMapForDirectory(currentDir);
184
+
30
185
  // Parse the Ruby code into an AST once
31
186
  let ast;
32
187
  try {
@@ -36,8 +191,8 @@ async function analyzeRubyFile(filePath, customFunctionSignatures = null) {
36
191
  return [];
37
192
  }
38
193
 
39
- // Single visitor pass covering all custom configs
40
- const visitor = new TrackingVisitor(code, filePath, customFunctionSignatures || []);
194
+ // Single visitor pass covering all custom configs, with constant map for resolution
195
+ const visitor = new TrackingVisitor(code, filePath, customFunctionSignatures || [], constantMap);
41
196
  const events = await visitor.analyze(ast);
42
197
 
43
198
  // Deduplicate events
@@ -46,7 +46,9 @@ async function traverseNode(node, nodeVisitor, ancestors = []) {
46
46
  AssocNode,
47
47
  ClassNode,
48
48
  ModuleNode,
49
- CallNode
49
+ CallNode,
50
+ CaseNode,
51
+ WhenNode
50
52
  } = await import('@ruby/prism');
51
53
 
52
54
  if (!node) return;
@@ -89,7 +91,8 @@ async function traverseNode(node, nodeVisitor, ancestors = []) {
89
91
  await traverseNode(node.body, nodeVisitor, ancestors);
90
92
  }
91
93
  } else if (node instanceof ArgumentsNode) {
92
- for (const arg of node.arguments) {
94
+ const argsList = node.arguments || [];
95
+ for (const arg of argsList) {
93
96
  await traverseNode(arg, nodeVisitor, ancestors);
94
97
  }
95
98
  } else if (node instanceof HashNode) {
@@ -99,6 +102,55 @@ async function traverseNode(node, nodeVisitor, ancestors = []) {
99
102
  } else if (node instanceof AssocNode) {
100
103
  await traverseNode(node.key, nodeVisitor, ancestors);
101
104
  await traverseNode(node.value, nodeVisitor, ancestors);
105
+ } else if (node instanceof CaseNode) {
106
+ // Traverse through each 'when' clause and the optional else clause
107
+ const whenClauses = node.whens || node.conditions || node.when_bodies || [];
108
+ for (const when of whenClauses) {
109
+ await traverseNode(when, nodeVisitor, ancestors);
110
+ }
111
+ if (node.else_) {
112
+ await traverseNode(node.else_, nodeVisitor, ancestors);
113
+ } else if (node.elseBody) {
114
+ await traverseNode(node.elseBody, nodeVisitor, ancestors);
115
+ }
116
+ } else if (node instanceof WhenNode) {
117
+ // Handle a single when clause: traverse its condition(s) and body
118
+ if (Array.isArray(node.conditions)) {
119
+ for (const cond of node.conditions) {
120
+ await traverseNode(cond, nodeVisitor, ancestors);
121
+ }
122
+ } else if (node.conditions) {
123
+ await traverseNode(node.conditions, nodeVisitor, ancestors);
124
+ }
125
+ if (node.statements) {
126
+ await traverseNode(node.statements, nodeVisitor, ancestors);
127
+ }
128
+ if (node.next) {
129
+ await traverseNode(node.next, nodeVisitor, ancestors);
130
+ }
131
+ } else {
132
+ // Generic fallback: iterate over enumerable properties to find nested nodes
133
+ for (const key of Object.keys(node)) {
134
+ const val = node[key];
135
+ if (!val) continue;
136
+
137
+ const visitChild = async (child) => {
138
+ if (child && typeof child === 'object') {
139
+ // crude check: Prism nodes have a `location` field
140
+ if (child.location || child.type || child.constructor?.name?.endsWith('Node')) {
141
+ await traverseNode(child, nodeVisitor, ancestors);
142
+ }
143
+ }
144
+ };
145
+
146
+ if (Array.isArray(val)) {
147
+ for (const c of val) {
148
+ await visitChild(c);
149
+ }
150
+ } else {
151
+ await visitChild(val);
152
+ }
153
+ }
102
154
  }
103
155
 
104
156
  ancestors.pop();
@@ -8,10 +8,11 @@ const { extractEventName, extractProperties } = require('./extractors');
8
8
  const { findWrappingFunction, traverseNode, getLineNumber } = require('./traversal');
9
9
 
10
10
  class TrackingVisitor {
11
- constructor(code, filePath, customConfigs = []) {
11
+ constructor(code, filePath, customConfigs = [], constantMap = {}) {
12
12
  this.code = code;
13
13
  this.filePath = filePath;
14
14
  this.customConfigs = Array.isArray(customConfigs) ? customConfigs : [];
15
+ this.constantMap = constantMap || {};
15
16
  this.events = [];
16
17
  }
17
18
 
@@ -42,7 +43,7 @@ class TrackingVisitor {
42
43
 
43
44
  if (!source) return;
44
45
 
45
- const eventName = extractEventName(node, source, matchedConfig);
46
+ const eventName = await extractEventName(node, source, matchedConfig, this.constantMap);
46
47
  if (!eventName) return;
47
48
 
48
49
  const line = getLineNumber(this.code, node.location);
@@ -296,27 +296,75 @@ function resolvePropertyAccessToString(node, checker, sourceFile) {
296
296
  try {
297
297
  // Get the symbol for the property access
298
298
  const symbol = checker.getSymbolAtLocation(node);
299
- if (!symbol || !symbol.valueDeclaration) {
300
- return null;
301
- }
302
-
303
- // Check if it's a property assignment with a string initializer
304
- if (ts.isPropertyAssignment(symbol.valueDeclaration) &&
305
- symbol.valueDeclaration.initializer &&
306
- ts.isStringLiteral(symbol.valueDeclaration.initializer)) {
307
- return symbol.valueDeclaration.initializer.text;
299
+ if (symbol && symbol.valueDeclaration) {
300
+ // Check if it's a property assignment with a string initializer
301
+ if (ts.isPropertyAssignment(symbol.valueDeclaration) &&
302
+ symbol.valueDeclaration.initializer &&
303
+ ts.isStringLiteral(symbol.valueDeclaration.initializer)) {
304
+ return symbol.valueDeclaration.initializer.text;
305
+ }
306
+
307
+ // Check if it's a variable declaration property (string literal type)
308
+ if (ts.isPropertySignature(symbol.valueDeclaration) ||
309
+ ts.isMethodSignature(symbol.valueDeclaration)) {
310
+ const type = checker.getTypeAtLocation(node);
311
+ if (type && type.isStringLiteral && type.isStringLiteral()) {
312
+ return type.value;
313
+ }
314
+ }
308
315
  }
309
-
310
- // Check if it's a variable declaration property
311
- if (ts.isPropertySignature(symbol.valueDeclaration) ||
312
- ts.isMethodSignature(symbol.valueDeclaration)) {
313
- // Try to get the type and see if it's a string literal type
314
- const type = checker.getTypeAtLocation(node);
315
- if (type.isStringLiteral && type.isStringLiteral()) {
316
- return type.value;
316
+
317
+ // ---------------------------------------------------------------------
318
+ // Fallback – manually resolve patterns like:
319
+ // const CONST = { KEY: 'value' };
320
+ // const CONST = Object.freeze({ KEY: 'value' });
321
+ // And later used as CONST.KEY
322
+ // ---------------------------------------------------------------------
323
+ if (ts.isIdentifier(node.expression)) {
324
+ const objIdentifier = node.expression;
325
+ const initializer = resolveIdentifierToInitializer(checker, objIdentifier, sourceFile);
326
+ if (initializer) {
327
+ let objectLiteral = null;
328
+
329
+ // Handle direct object literal initializers
330
+ if (ts.isObjectLiteralExpression(initializer)) {
331
+ objectLiteral = initializer;
332
+ }
333
+ // Handle Object.freeze({ ... }) pattern
334
+ else if (ts.isCallExpression(initializer)) {
335
+ const callee = initializer.expression;
336
+ if (
337
+ ts.isPropertyAccessExpression(callee) &&
338
+ ts.isIdentifier(callee.expression) &&
339
+ callee.expression.escapedText === 'Object' &&
340
+ callee.name.escapedText === 'freeze' &&
341
+ initializer.arguments.length > 0 &&
342
+ ts.isObjectLiteralExpression(initializer.arguments[0])
343
+ ) {
344
+ objectLiteral = initializer.arguments[0];
345
+ }
346
+ }
347
+
348
+ if (objectLiteral) {
349
+ const propNode = findPropertyByKey(objectLiteral, node.name.escapedText || node.name.text);
350
+ if (propNode && propNode.initializer && ts.isStringLiteral(propNode.initializer)) {
351
+ return propNode.initializer.text;
352
+ }
353
+ }
317
354
  }
318
355
  }
319
-
356
+
357
+ // Final fallback – use type information at location (works for imported Object.freeze constants)
358
+ try {
359
+ const t = checker.getTypeAtLocation(node);
360
+ if (t && t.isStringLiteral && typeof t.isStringLiteral === 'function' && t.isStringLiteral()) {
361
+ return t.value;
362
+ }
363
+ if (t && t.flags && (t.flags & ts.TypeFlags.StringLiteral)) {
364
+ return t.value;
365
+ }
366
+ } catch (_) {/* ignore */}
367
+
320
368
  return null;
321
369
  } catch (error) {
322
370
  return null;
@@ -7,6 +7,7 @@ const ts = require('typescript');
7
7
  const { detectAnalyticsSource } = require('./detectors');
8
8
  const { extractEventData, processEventData } = require('./extractors');
9
9
  const { findWrappingFunction } = require('./utils/function-finder');
10
+ const path = require('path');
10
11
 
11
12
  /**
12
13
  * Error thrown when TypeScript program cannot be created
@@ -44,16 +45,37 @@ function getProgram(filePath, existingProgram) {
44
45
  }
45
46
 
46
47
  try {
47
- // Create a minimal program for single file analysis
48
- const options = {
48
+ // Try to locate a tsconfig.json nearest to the file to inherit compiler options (important for path aliases)
49
+ const searchPath = path.dirname(filePath);
50
+ const configPath = ts.findConfigFile(searchPath, ts.sys.fileExists, 'tsconfig.json');
51
+
52
+ let compilerOptions = {
49
53
  target: ts.ScriptTarget.Latest,
50
54
  module: ts.ModuleKind.CommonJS,
51
55
  allowJs: true,
52
56
  checkJs: false,
53
- noEmit: true
57
+ noEmit: true,
58
+ jsx: ts.JsxEmit.Preserve
54
59
  };
60
+ let rootNames = [filePath];
61
+
62
+ if (configPath) {
63
+ // Read and parse the tsconfig.json
64
+ const readResult = ts.readConfigFile(configPath, ts.sys.readFile);
65
+ if (!readResult.error && readResult.config) {
66
+ const parseResult = ts.parseJsonConfigFileContent(
67
+ readResult.config,
68
+ ts.sys,
69
+ path.dirname(configPath)
70
+ );
71
+ if (!parseResult.errors || parseResult.errors.length === 0) {
72
+ compilerOptions = { ...compilerOptions, ...parseResult.options };
73
+ rootNames = parseResult.fileNames.length > 0 ? parseResult.fileNames : rootNames;
74
+ }
75
+ }
76
+ }
55
77
 
56
- const program = ts.createProgram([filePath], options);
78
+ const program = ts.createProgram(rootNames, compilerOptions);
57
79
  return program;
58
80
  } catch (error) {
59
81
  throw new ProgramError(filePath, error);