Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions src/analyze/ruby/extractors.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@
*/

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

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

const eventArg = args[customConfig.eventIndex];
if (eventArg?.unescaped?.value) {
return eventArg.unescaped.value;
if (eventArg) {
// String literal
if (eventArg.unescaped?.value) {
return eventArg.unescaped.value;
}

// Constant references
const { ConstantReadNode, ConstantPathNode } = await prismPromise;
const buildConstPath = (n) => {
if (!n) return '';
if (n instanceof ConstantReadNode) return n.name;
if (n instanceof ConstantPathNode) {
const parent = buildConstPath(n.parent);
return parent ? `${parent}::${n.name}` : n.name;
}
return '';
};

if (eventArg instanceof ConstantReadNode) {
const name = eventArg.name;
// Try to resolve with current constant map, else return name
return constantMap[name] || name;
}
if (eventArg instanceof ConstantPathNode) {
const pathStr = buildConstPath(eventArg);
return constantMap[pathStr] || pathStr;
}
}
return null;
}
Expand All @@ -79,7 +106,7 @@ function extractEventName(node, source, customConfig = null) {
* @returns {Object|null} - The extracted properties or null
*/
async function extractProperties(node, source, customConfig = null) {
const { HashNode, ArrayNode } = await import('@ruby/prism');
const { HashNode, ArrayNode } = await prismPromise;

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

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

if (arrayNode.elements.length === 0) {
return { type: 'any' };
Expand Down
159 changes: 157 additions & 2 deletions src/analyze/ruby/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,162 @@
*/

const fs = require('fs');
const path = require('path');
const TrackingVisitor = require('./visitor');

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

/**
* Extracts the string literal value from an AST node, ignoring a trailing `.freeze` call.
* Supports:
* - StringNode
* - CallNode with receiver StringNode and method `freeze`
*
* @param {import('@ruby/prism').PrismNode} node
* @returns {string|null}
*/
async function extractStringLiteral(node) {
if (!node) return null;

const {
StringNode,
CallNode
} = await import('@ruby/prism');

if (node instanceof StringNode) {
return node.unescaped?.value ?? null;
}

// Handle "_Section".freeze pattern
if (node instanceof CallNode && node.name === 'freeze' && node.receiver) {
return extractStringLiteral(node.receiver);
}

return null;
}

/**
* Recursively traverses an AST to collect constant assignments and build a map
* of fully-qualified constant names (e.g. "TelemetryHelper::FINISHED_SECTION")
* to their string literal values.
*
* @param {import('@ruby/prism').PrismNode} node - current AST node
* @param {string[]} namespaceStack - stack of module/class names
* @param {Object} constantMap - accumulator map of constant path -> string value
*/
async function collectConstants(node, namespaceStack, constantMap) {
if (!node) return;

const {
ModuleNode,
ClassNode,
StatementsNode,
ConstantWriteNode,
ConstantPathWriteNode,
ConstantPathNode
} = await import('@ruby/prism');

// Helper to build constant path from ConstantPathNode
const buildConstPath = (pathNode) => {
if (!pathNode) return '';
if (pathNode.type === 'ConstantReadNode') {
return pathNode.name;
}
if (pathNode.type === 'ConstantPathNode') {
const parent = buildConstPath(pathNode.parent);
return parent ? `${parent}::${pathNode.name}` : pathNode.name;
}
return '';
};

// Process constant assignments
if (node instanceof ConstantWriteNode) {
const fullName = [...namespaceStack, node.name].join('::');
const literal = await extractStringLiteral(node.value);
if (literal !== null) {
constantMap[fullName] = literal;
}
} else if (node instanceof ConstantPathWriteNode) {
const fullName = buildConstPath(node.target);
const literal = await extractStringLiteral(node.value);
if (fullName && literal !== null) {
constantMap[fullName] = literal;
}
}

// Recurse into children depending on node type
if (node instanceof ModuleNode || node instanceof ClassNode) {
// Enter namespace
const name = node.constantPath?.name || node.name; // ModuleNode has constantPath
const childNamespaceStack = name ? [...namespaceStack, name] : namespaceStack;

if (node.body) {
await collectConstants(node.body, childNamespaceStack, constantMap);
}
return;
}

// Generic traversal for other nodes
if (node instanceof StatementsNode) {
for (const child of node.body) {
await collectConstants(child, namespaceStack, constantMap);
}
return;
}

// Fallback: iterate over enumerable properties to find nested nodes
for (const key of Object.keys(node)) {
const val = node[key];
if (!val) continue;

const traverseChild = async (child) => {
if (child && typeof child === 'object' && (child.location || child.constructor?.name?.endsWith('Node'))) {
await collectConstants(child, namespaceStack, constantMap);
}
};

if (Array.isArray(val)) {
for (const c of val) {
await traverseChild(c);
}
} else {
await traverseChild(val);
}
}
}

/**
* Builds a map of constant names to their literal string values for all .rb
* files in the given directory. This is a best-effort resolver intended for
* test fixtures and small projects and is not a fully-fledged Ruby constant
* resolver.
*
* @param {string} directory
* @returns {Promise<Object<string,string>>}
*/
async function buildConstantMapForDirectory(directory) {
const constantMap = {};

if (!fs.existsSync(directory)) return constantMap;

const files = fs.readdirSync(directory).filter(f => f.endsWith('.rb'));

for (const file of files) {
const fullPath = path.join(directory, file);
try {
const content = fs.readFileSync(fullPath, 'utf8');
const ast = await parse(content);
await collectConstants(ast.value, [], constantMap);
} catch (err) {
// Ignore parse errors for unrelated files
continue;
}
}

return constantMap;
}

/**
* Analyzes a Ruby file for analytics tracking calls
* @param {string} filePath - Path to the Ruby file to analyze
Expand All @@ -27,6 +178,10 @@ async function analyzeRubyFile(filePath, customFunctionSignatures = null) {
// Read the file content
const code = fs.readFileSync(filePath, 'utf8');

// Build constant map for current directory (sibling .rb files)
const currentDir = path.dirname(filePath);
const constantMap = await buildConstantMapForDirectory(currentDir);

// Parse the Ruby code into an AST once
let ast;
try {
Expand All @@ -36,8 +191,8 @@ async function analyzeRubyFile(filePath, customFunctionSignatures = null) {
return [];
}

// Single visitor pass covering all custom configs
const visitor = new TrackingVisitor(code, filePath, customFunctionSignatures || []);
// Single visitor pass covering all custom configs, with constant map for resolution
const visitor = new TrackingVisitor(code, filePath, customFunctionSignatures || [], constantMap);
const events = await visitor.analyze(ast);

// Deduplicate events
Expand Down
56 changes: 54 additions & 2 deletions src/analyze/ruby/traversal.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ async function traverseNode(node, nodeVisitor, ancestors = []) {
AssocNode,
ClassNode,
ModuleNode,
CallNode
CallNode,
CaseNode,
WhenNode
} = await import('@ruby/prism');

if (!node) return;
Expand Down Expand Up @@ -89,7 +91,8 @@ async function traverseNode(node, nodeVisitor, ancestors = []) {
await traverseNode(node.body, nodeVisitor, ancestors);
}
} else if (node instanceof ArgumentsNode) {
for (const arg of node.arguments) {
const argsList = node.arguments || [];
for (const arg of argsList) {
await traverseNode(arg, nodeVisitor, ancestors);
}
} else if (node instanceof HashNode) {
Expand All @@ -99,6 +102,55 @@ async function traverseNode(node, nodeVisitor, ancestors = []) {
} else if (node instanceof AssocNode) {
await traverseNode(node.key, nodeVisitor, ancestors);
await traverseNode(node.value, nodeVisitor, ancestors);
} else if (node instanceof CaseNode) {
// Traverse through each 'when' clause and the optional else clause
const whenClauses = node.whens || node.conditions || node.when_bodies || [];
for (const when of whenClauses) {
await traverseNode(when, nodeVisitor, ancestors);
}
if (node.else_) {
await traverseNode(node.else_, nodeVisitor, ancestors);
} else if (node.elseBody) {
await traverseNode(node.elseBody, nodeVisitor, ancestors);
}
} else if (node instanceof WhenNode) {
// Handle a single when clause: traverse its condition(s) and body
if (Array.isArray(node.conditions)) {
for (const cond of node.conditions) {
await traverseNode(cond, nodeVisitor, ancestors);
}
} else if (node.conditions) {
await traverseNode(node.conditions, nodeVisitor, ancestors);
}
if (node.statements) {
await traverseNode(node.statements, nodeVisitor, ancestors);
}
if (node.next) {
await traverseNode(node.next, nodeVisitor, ancestors);
}
} else {
// Generic fallback: iterate over enumerable properties to find nested nodes
for (const key of Object.keys(node)) {
const val = node[key];
if (!val) continue;

const visitChild = async (child) => {
if (child && typeof child === 'object') {
// crude check: Prism nodes have a `location` field
if (child.location || child.type || child.constructor?.name?.endsWith('Node')) {
await traverseNode(child, nodeVisitor, ancestors);
}
}
};

if (Array.isArray(val)) {
for (const c of val) {
await visitChild(c);
}
} else {
await visitChild(val);
}
}
}

ancestors.pop();
Expand Down
5 changes: 3 additions & 2 deletions src/analyze/ruby/visitor.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ const { extractEventName, extractProperties } = require('./extractors');
const { findWrappingFunction, traverseNode, getLineNumber } = require('./traversal');

class TrackingVisitor {
constructor(code, filePath, customConfigs = []) {
constructor(code, filePath, customConfigs = [], constantMap = {}) {
this.code = code;
this.filePath = filePath;
this.customConfigs = Array.isArray(customConfigs) ? customConfigs : [];
this.constantMap = constantMap || {};
this.events = [];
}

Expand Down Expand Up @@ -42,7 +43,7 @@ class TrackingVisitor {

if (!source) return;

const eventName = extractEventName(node, source, matchedConfig);
const eventName = await extractEventName(node, source, matchedConfig, this.constantMap);
if (!eventName) return;

const line = getLineNumber(this.code, node.location);
Expand Down
Loading