44 */
55
66const fs = require ( 'fs' ) ;
7+ const path = require ( 'path' ) ;
78const TrackingVisitor = require ( './visitor' ) ;
89
910// Lazy-loaded parse function from Ruby Prism
1011let 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
0 commit comments