diff --git a/src/analyze/ruby/extractors.js b/src/analyze/ruby/extractors.js index 52a7184..a39fe30 100644 --- a/src/analyze/ruby/extractors.js +++ b/src/analyze/ruby/extractors.js @@ -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; @@ -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; } @@ -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 @@ -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) { @@ -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' }; diff --git a/src/analyze/ruby/index.js b/src/analyze/ruby/index.js index 54a5320..95dc93b 100644 --- a/src/analyze/ruby/index.js +++ b/src/analyze/ruby/index.js @@ -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>} + */ +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 @@ -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 { @@ -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 diff --git a/src/analyze/ruby/traversal.js b/src/analyze/ruby/traversal.js index bcf74fa..add5367 100644 --- a/src/analyze/ruby/traversal.js +++ b/src/analyze/ruby/traversal.js @@ -46,7 +46,9 @@ async function traverseNode(node, nodeVisitor, ancestors = []) { AssocNode, ClassNode, ModuleNode, - CallNode + CallNode, + CaseNode, + WhenNode } = await import('@ruby/prism'); if (!node) return; @@ -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) { @@ -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(); diff --git a/src/analyze/ruby/visitor.js b/src/analyze/ruby/visitor.js index e29ca36..d60bc25 100644 --- a/src/analyze/ruby/visitor.js +++ b/src/analyze/ruby/visitor.js @@ -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 = []; } @@ -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); diff --git a/tests/analyzeRuby.test.js b/tests/analyzeRuby.test.js index 25468e0..ba2bad2 100644 --- a/tests/analyzeRuby.test.js +++ b/tests/analyzeRuby.test.js @@ -289,4 +289,57 @@ test.describe('analyzeRubyFile', () => { const builtInCount = events.filter(e => e.source !== 'custom').length; assert.ok(builtInCount >= 6, 'Should still include built-in events'); }); + + test('should detect CustomModule.track inside case/when blocks', async () => { + const registrationFile = path.join(fixturesDir, 'ruby', 'registration_module.rb'); + const customSignature = parseCustomFunctionSignature('CustomModule.track(userId, EVENT_NAME, PROPERTIES)'); + const events = await analyzeRubyFile(registrationFile, [customSignature]); + + assert.strictEqual(events.length, 1); + const evt = events[0]; + assert.strictEqual(evt.eventName, 'BecameLead'); + assert.strictEqual(evt.source, 'custom'); + // function name should be CustomModule.track + assert.strictEqual(evt.functionName, 'CustomModule.track'); + assert.deepStrictEqual(evt.properties, { + userId: { type: 'any' }, + leadType: { type: 'string' }, + nonInteraction: { type: 'number' } + }); + }); + + test('should detect events in various ruby node types', async () => { + const nodeFile = path.join(fixturesDir, 'ruby', 'node_types.rb'); + const sig = parseCustomFunctionSignature('CustomModule.track(userId, EVENT_NAME, PROPERTIES)'); + const events = await analyzeRubyFile(nodeFile, [sig]); + + const expected = ['UnlessEvent','WhileEvent','ForEvent','RescueEvent','EnsureEvent','LambdaEvent','ArrayEvent','AndEvent','OrEvent','InterpolationEvent']; + + expected.forEach(ev => { + const found = events.find(e => e.eventName === ev); + assert.ok(found, `Missing ${ev}`); + }); + }); + + test('should detect event names passed as constant references', async () => { + const constFile = path.join(fixturesDir, 'ruby', 'constant_event.rb'); + const sig = parseCustomFunctionSignature('CustomModule.track(userId, EVENT_NAME, PROPERTIES)'); + const events = await analyzeRubyFile(constFile, [sig]); + const evt = events.find(e => e.eventName === '_FinishedSection'); + assert.ok(evt); + assert.deepStrictEqual(evt.properties, { + userId: { type: 'any' } + }); + }); + + test('should detect event when constant is defined in another file', async () => { + const useFile = path.join(fixturesDir, 'ruby', 'external_constant_use.rb'); + const sig = parseCustomFunctionSignature('CustomModule.track(userId, EVENT_NAME, PROPERTIES)'); + const events = await analyzeRubyFile(useFile, [sig]); + const evt = events.find(e => e.eventName === '_ExternalSection'); + assert.ok(evt); + assert.deepStrictEqual(evt.properties, { + userId: { type: 'number' } + }); + }); }); diff --git a/tests/fixtures/ruby/constant_event.rb b/tests/fixtures/ruby/constant_event.rb new file mode 100644 index 0000000..af76ed9 --- /dev/null +++ b/tests/fixtures/ruby/constant_event.rb @@ -0,0 +1,10 @@ +module TelemetryHelper + FINISHED_SECTION = '_FinishedSection'.freeze +end + +module ConstantEventExample + def send_event(kase) + data = { foo: 'bar' } + CustomModule.track(kase.id, TelemetryHelper::FINISHED_SECTION, data) + end +end \ No newline at end of file diff --git a/tests/fixtures/ruby/constants_def.rb b/tests/fixtures/ruby/constants_def.rb new file mode 100644 index 0000000..b9013c1 --- /dev/null +++ b/tests/fixtures/ruby/constants_def.rb @@ -0,0 +1,3 @@ +module TelemetryHelper + EXTERNAL_SECTION = '_ExternalSection'.freeze +end \ No newline at end of file diff --git a/tests/fixtures/ruby/external_constant_use.rb b/tests/fixtures/ruby/external_constant_use.rb new file mode 100644 index 0000000..adba8cb --- /dev/null +++ b/tests/fixtures/ruby/external_constant_use.rb @@ -0,0 +1,5 @@ +module ExternalConstantExample + def trigger + CustomModule.track(123, TelemetryHelper::EXTERNAL_SECTION, {}) + end +end \ No newline at end of file diff --git a/tests/fixtures/ruby/node_types.rb b/tests/fixtures/ruby/node_types.rb new file mode 100644 index 0000000..be1d79a --- /dev/null +++ b/tests/fixtures/ruby/node_types.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module NodeTypesExample + def unless_example(flag) + unless flag + CustomModule.track('user1', 'UnlessEvent', { a: 1 }) + end + end + + def while_example + i = 0 + while i < 1 + CustomModule.track('user1', 'WhileEvent', { b: 2 }) + i += 1 + end + end +end + +for x in [1, 2] + CustomModule.track('user1', 'ForEvent', { c: 3 }) +end + +begin + raise 'error' +rescue + CustomModule.track('user1', 'RescueEvent', { d: 4 }) +ensure + CustomModule.track('user1', 'EnsureEvent', { e: 5 }) +end + +lambda_example = -> { + CustomModule.track('user1', 'LambdaEvent', { f: 6 }) +} + +["hi", CustomModule.track('user1', 'ArrayEvent', { g: 7 })] + +CustomModule.track('user1', 'AndEvent', { h: 8 }) && true +false || CustomModule.track('user1', 'OrEvent', { i: 9 }) + +string = "test \\#{CustomModule.track('user1', 'InterpolationEvent', { j: 10 })}" diff --git a/tests/fixtures/ruby/registration_module.rb b/tests/fixtures/ruby/registration_module.rb new file mode 100644 index 0000000..c703068 --- /dev/null +++ b/tests/fixtures/ruby/registration_module.rb @@ -0,0 +1,20 @@ +module User + module Registration + def post_registration(case: nil) + context_case = case || OpenStruct.new(kind: 'TestCase') + + case context_case.kind + when 'TestCase' + # Tracking event that was previously missed + CustomModule.track(id, 'BecameLead', { + leadType: 'EMAIL_CAPTURED', + nonInteraction: 1 + }) + when 'OtherKind' + # no-op + else + # nothing + end + end + end +end diff --git a/tests/fixtures/ruby/tracking-schema-ruby.yaml b/tests/fixtures/ruby/tracking-schema-ruby.yaml index 27263a1..288e482 100644 --- a/tests/fixtures/ruby/tracking-schema-ruby.yaml +++ b/tests/fixtures/ruby/tracking-schema-ruby.yaml @@ -109,6 +109,129 @@ events: type: string from_module: type: boolean + UnlessEvent: + implementations: + - path: node_types.rb + line: 6 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + a: + type: number + WhileEvent: + implementations: + - path: node_types.rb + line: 13 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + b: + type: number + ForEvent: + implementations: + - path: node_types.rb + line: 20 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + c: + type: number + RescueEvent: + implementations: + - path: node_types.rb + line: 26 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + d: + type: number + EnsureEvent: + implementations: + - path: node_types.rb + line: 28 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + e: + type: number + LambdaEvent: + implementations: + - path: node_types.rb + line: 32 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + f: + type: number + ArrayEvent: + implementations: + - path: node_types.rb + line: 35 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + g: + type: number + AndEvent: + implementations: + - path: node_types.rb + line: 37 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + h: + type: number + OrEvent: + implementations: + - path: node_types.rb + line: 38 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + i: + type: number + InterpolationEvent: + implementations: + - path: node_types.rb + line: 40 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + j: + type: number + BecameLead: + implementations: + - path: registration_module.rb + line: 9 + function: CustomModule.track + destination: custom + properties: + userId: + type: any + leadType: + type: string + nonInteraction: + type: number custom_event0: implementations: - path: main.rb @@ -164,3 +287,21 @@ events: type: string foo: type: string + _FinishedSection: + implementations: + - path: constant_event.rb + line: 8 + function: CustomModule.track + destination: custom + properties: + userId: + type: any + _ExternalSection: + implementations: + - path: external_constant_use.rb + line: 3 + function: CustomModule.track + destination: custom + properties: + userId: + type: number diff --git a/tests/fixtures/tracking-schema-all.yaml b/tests/fixtures/tracking-schema-all.yaml index 4b9c07e..d179742 100644 --- a/tests/fixtures/tracking-schema-all.yaml +++ b/tests/fixtures/tracking-schema-all.yaml @@ -624,6 +624,19 @@ events: type: string from_module: type: boolean + BecameLead: + implementations: + - path: ruby/registration_module.rb + line: 9 + function: CustomModule.track + destination: custom + properties: + userId: + type: any + leadType: + type: string + nonInteraction: + type: number order_completed: implementations: - path: typescript/main.ts @@ -1144,3 +1157,131 @@ events: properties: foo: type: string + UnlessEvent: + implementations: + - path: ruby/node_types.rb + line: 6 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + a: + type: number + WhileEvent: + implementations: + - path: ruby/node_types.rb + line: 13 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + b: + type: number + ForEvent: + implementations: + - path: ruby/node_types.rb + line: 20 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + c: + type: number + RescueEvent: + implementations: + - path: ruby/node_types.rb + line: 26 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + d: + type: number + EnsureEvent: + implementations: + - path: ruby/node_types.rb + line: 28 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + e: + type: number + LambdaEvent: + implementations: + - path: ruby/node_types.rb + line: 32 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + f: + type: number + ArrayEvent: + implementations: + - path: ruby/node_types.rb + line: 35 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + g: + type: number + AndEvent: + implementations: + - path: ruby/node_types.rb + line: 37 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + h: + type: number + OrEvent: + implementations: + - path: ruby/node_types.rb + line: 38 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + i: + type: number + InterpolationEvent: + implementations: + - path: ruby/node_types.rb + line: 40 + function: CustomModule.track + destination: custom + properties: + userId: + type: string + j: + type: number + _FinishedSection: + implementations: + - path: ruby/constant_event.rb + line: 8 + function: CustomModule.track + destination: custom + properties: + userId: + type: any + _ExternalSection: + implementations: + - path: ruby/external_constant_use.rb + line: 3 + function: CustomModule.track + destination: custom + properties: + userId: + type: number diff --git a/tests/fixtures/typescript-alias/app/components/main.ts b/tests/fixtures/typescript-alias/app/components/main.ts index 4f2f71f..05accfc 100644 --- a/tests/fixtures/typescript-alias/app/components/main.ts +++ b/tests/fixtures/typescript-alias/app/components/main.ts @@ -9,4 +9,4 @@ const mixpanel: any = { // Event that should be detected via constant reference mixpanel.track(TELEMETRY_EVENTS.VIEWED_PAGE, { foo: 'bar' -}); \ No newline at end of file +}); diff --git a/tests/fixtures/typescript-alias/lib/constants.ts b/tests/fixtures/typescript-alias/lib/constants.ts index 0900ac3..697063d 100644 --- a/tests/fixtures/typescript-alias/lib/constants.ts +++ b/tests/fixtures/typescript-alias/lib/constants.ts @@ -1,4 +1,4 @@ export const TELEMETRY_EVENTS = Object.freeze({ VIEWED_PAGE: 'ViewedPage', VIEWED_QUESTION: 'ViewedQuestion' -}); \ No newline at end of file +}); diff --git a/tests/fixtures/typescript-alias/tsconfig.json b/tests/fixtures/typescript-alias/tsconfig.json index e5fbaa8..539dd3f 100644 --- a/tests/fixtures/typescript-alias/tsconfig.json +++ b/tests/fixtures/typescript-alias/tsconfig.json @@ -11,4 +11,4 @@ }, "include": ["**/*"], "exclude": ["node_modules"] -} \ No newline at end of file +}