Skip to content

Commit 0b31e80

Browse files
committed
extract event names stored as constants
1 parent d129e98 commit 0b31e80

File tree

9 files changed

+269
-10
lines changed

9 files changed

+269
-10
lines changed

src/analyze/ruby/extractors.js

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44
*/
55

66
const { getValueType } = require('./types');
7+
const prismPromise = import('@ruby/prism');
78

89
/**
910
* Extracts the event name from a tracking call based on the source
1011
* @param {Object} node - The AST CallNode
1112
* @param {string} source - The detected analytics source
1213
* @param {Object} customConfig - Custom configuration for custom functions
14+
* @param {Object} constantMap - Map of constants to resolve constant paths
1315
* @returns {string|null} - The extracted event name or null
1416
*/
15-
function extractEventName(node, source, customConfig = null) {
17+
async function extractEventName(node, source, customConfig = null, constantMap = {}) {
1618
if (source === 'segment' || source === 'rudderstack') {
1719
// Both Segment and Rudderstack use the same format
1820
const params = node.arguments_?.arguments_?.[0]?.elements;
@@ -62,8 +64,33 @@ function extractEventName(node, source, customConfig = null) {
6264
}
6365

6466
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+
}
6794
}
6895
return null;
6996
}
@@ -79,7 +106,7 @@ function extractEventName(node, source, customConfig = null) {
79106
* @returns {Object|null} - The extracted properties or null
80107
*/
81108
async function extractProperties(node, source, customConfig = null) {
82-
const { HashNode, ArrayNode } = await import('@ruby/prism');
109+
const { HashNode, ArrayNode } = await prismPromise;
83110

84111
if (source === 'segment' || source === 'rudderstack') {
85112
// Both Segment and Rudderstack use the same format
@@ -235,7 +262,7 @@ async function extractProperties(node, source, customConfig = null) {
235262
* @returns {Object} - The extracted properties
236263
*/
237264
async function extractHashProperties(hashNode) {
238-
const { AssocNode, HashNode, ArrayNode } = await import('@ruby/prism');
265+
const { AssocNode, HashNode, ArrayNode } = await prismPromise;
239266
const properties = {};
240267

241268
for (const element of hashNode.elements) {
@@ -278,7 +305,7 @@ async function extractHashProperties(hashNode) {
278305
* @returns {Object} - Type information for array items
279306
*/
280307
async function extractArrayItemProperties(arrayNode) {
281-
const { HashNode } = await import('@ruby/prism');
308+
const { HashNode } = await prismPromise;
282309

283310
if (arrayNode.elements.length === 0) {
284311
return { type: 'any' };

src/analyze/ruby/index.js

Lines changed: 157 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,162 @@
44
*/
55

66
const fs = require('fs');
7+
const path = require('path');
78
const TrackingVisitor = require('./visitor');
89

910
// Lazy-loaded parse function from Ruby Prism
1011
let parse = null;
1112

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+
12163
/**
13164
* Analyzes a Ruby file for analytics tracking calls
14165
* @param {string} filePath - Path to the Ruby file to analyze
@@ -27,6 +178,10 @@ async function analyzeRubyFile(filePath, customFunctionSignatures = null) {
27178
// Read the file content
28179
const code = fs.readFileSync(filePath, 'utf8');
29180

181+
// Build constant map for current directory (sibling .rb files)
182+
const currentDir = path.dirname(filePath);
183+
const constantMap = await buildConstantMapForDirectory(currentDir);
184+
30185
// Parse the Ruby code into an AST once
31186
let ast;
32187
try {
@@ -36,8 +191,8 @@ async function analyzeRubyFile(filePath, customFunctionSignatures = null) {
36191
return [];
37192
}
38193

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);
41196
const events = await visitor.analyze(ast);
42197

43198
// Deduplicate events

src/analyze/ruby/visitor.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,11 @@ const { extractEventName, extractProperties } = require('./extractors');
88
const { findWrappingFunction, traverseNode, getLineNumber } = require('./traversal');
99

1010
class TrackingVisitor {
11-
constructor(code, filePath, customConfigs = []) {
11+
constructor(code, filePath, customConfigs = [], constantMap = {}) {
1212
this.code = code;
1313
this.filePath = filePath;
1414
this.customConfigs = Array.isArray(customConfigs) ? customConfigs : [];
15+
this.constantMap = constantMap || {};
1516
this.events = [];
1617
}
1718

@@ -42,7 +43,7 @@ class TrackingVisitor {
4243

4344
if (!source) return;
4445

45-
const eventName = extractEventName(node, source, matchedConfig);
46+
const eventName = await extractEventName(node, source, matchedConfig, this.constantMap);
4647
if (!eventName) return;
4748

4849
const line = getLineNumber(this.code, node.location);

tests/analyzeRuby.test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -320,4 +320,26 @@ test.describe('analyzeRubyFile', () => {
320320
assert.ok(found, `Missing ${ev}`);
321321
});
322322
});
323+
324+
test('should detect event names passed as constant references', async () => {
325+
const constFile = path.join(fixturesDir, 'ruby', 'constant_event.rb');
326+
const sig = parseCustomFunctionSignature('CustomModule.track(userId, EVENT_NAME, PROPERTIES)');
327+
const events = await analyzeRubyFile(constFile, [sig]);
328+
const evt = events.find(e => e.eventName === '_FinishedSection');
329+
assert.ok(evt);
330+
assert.deepStrictEqual(evt.properties, {
331+
userId: { type: 'any' }
332+
});
333+
});
334+
335+
test('should detect event when constant is defined in another file', async () => {
336+
const useFile = path.join(fixturesDir, 'ruby', 'external_constant_use.rb');
337+
const sig = parseCustomFunctionSignature('CustomModule.track(userId, EVENT_NAME, PROPERTIES)');
338+
const events = await analyzeRubyFile(useFile, [sig]);
339+
const evt = events.find(e => e.eventName === '_ExternalSection');
340+
assert.ok(evt);
341+
assert.deepStrictEqual(evt.properties, {
342+
userId: { type: 'number' }
343+
});
344+
});
323345
});
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
module TelemetryHelper
2+
FINISHED_SECTION = '_FinishedSection'.freeze
3+
end
4+
5+
module ConstantEventExample
6+
def send_event(kase)
7+
data = { foo: 'bar' }
8+
CustomModule.track(kase.id, TelemetryHelper::FINISHED_SECTION, data)
9+
end
10+
end
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
module TelemetryHelper
2+
EXTERNAL_SECTION = '_ExternalSection'.freeze
3+
end
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
module ExternalConstantExample
2+
def trigger
3+
CustomModule.track(123, TelemetryHelper::EXTERNAL_SECTION, {})
4+
end
5+
end

tests/fixtures/ruby/tracking-schema-ruby.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,3 +287,21 @@ events:
287287
type: string
288288
foo:
289289
type: string
290+
_FinishedSection:
291+
implementations:
292+
- path: constant_event.rb
293+
line: 8
294+
function: CustomModule.track
295+
destination: custom
296+
properties:
297+
userId:
298+
type: any
299+
_ExternalSection:
300+
implementations:
301+
- path: external_constant_use.rb
302+
line: 3
303+
function: CustomModule.track
304+
destination: custom
305+
properties:
306+
userId:
307+
type: number

tests/fixtures/tracking-schema-all.yaml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1267,3 +1267,21 @@ events:
12671267
type: string
12681268
j:
12691269
type: number
1270+
_FinishedSection:
1271+
implementations:
1272+
- path: ruby/constant_event.rb
1273+
line: 8
1274+
function: CustomModule.track
1275+
destination: custom
1276+
properties:
1277+
userId:
1278+
type: any
1279+
_ExternalSection:
1280+
implementations:
1281+
- path: ruby/external_constant_use.rb
1282+
line: 3
1283+
function: CustomModule.track
1284+
destination: custom
1285+
properties:
1286+
userId:
1287+
type: number

0 commit comments

Comments
 (0)