Skip to main content
Glama
sourceMapper.ts64.9 kB
import * as fs from "fs/promises"; import * as path from "path"; import { spawn } from "child_process"; import { glob } from "glob"; import { logger } from "./logger"; import { ActionableError, ActivityInfo, AppConfig, ApplicationModuleDetails, ComposableInfo, FragmentInfo, ProjectScanResult, ModuleMapping, SourceAnalysis, SourceIndexResult, TestPlanPlacementResult, ViewHierarchyAnalysis, ViewInfo, ViewHierarchyResult } from "../models"; import { ConfigurationManager } from "./configurationManager"; export class SourceMapper { private static instance: SourceMapper; private projectScanResultCache: Map<string, ProjectScanResult> = new Map(); private sourceIndex: Map<string, SourceIndexResult> = new Map(); private readonly sourceCacheDir: string; private constructor() { // home should either be process.env.HOME or bash resolution of home for current user const homeDir = process.env.HOME || require("os").homedir(); if (!homeDir) { throw new ActionableError("Home directory for current user not found"); } this.sourceCacheDir = path.join("/tmp", "auto-mobile", "source-cache"); this.ensureDirectoriesExist(); } public static getInstance(): SourceMapper { if (!SourceMapper.instance) { SourceMapper.instance = new SourceMapper(); } return SourceMapper.instance; } // =========================================== // App Configuration Management // =========================================== private ensureDirectoriesExist(): void { if (!require("fs").existsSync(this.sourceCacheDir)) { require("fs").mkdirSync(this.sourceCacheDir, { recursive: true }); } } /** * Add or update an app configuration */ public async addAppConfig(appId: string, sourceDir: string, platform: "android" | "ios"): Promise<void> { return ConfigurationManager.getInstance().setAppSource(appId, sourceDir, platform, false); } /** * Get all app configurations */ public getAppConfigs(): AppConfig[] { return ConfigurationManager.getInstance().getAppConfigs(); } /** * Get all app configurations */ public getMatchingAppConfig(appId: string): AppConfig | undefined { return ConfigurationManager.getInstance().getAppConfigs().find((config: { appId: string }) => appId.startsWith(config.appId) || config.appId.startsWith(appId) ); } /** * Get source directory for an app ID */ public getSourceDir(appId: string): string | null { return this.getMatchingAppConfig(appId)?.sourceDir || null; } // =========================================== // Module Discovery // =========================================== private async findAndroidApplicationIdentifiers(appId: string, projectRoot: string): Promise<string[]> { const appConfig = ConfigurationManager.getInstance().getConfigForApp(appId) || { appId: appId, platform: "android", data: new Map() } as AppConfig; const catalogIdentifier = await this.findAndroidApplicationCatalogPlugin(appConfig, projectRoot); logger.debug(`[SOURCE] Found catalog identifier: ${catalogIdentifier}`); const conventionPlugins = await this.findAndroidApplicationConventionPlugin(appConfig, projectRoot, catalogIdentifier); if (catalogIdentifier) { return [catalogIdentifier, ...conventionPlugins]; } return conventionPlugins; } /** * Parse gradle TOML files to find the android application plugin definition */ private async findAndroidApplicationCatalogPlugin(appConfig: AppConfig, projectRoot: string): Promise<string | null> { // Check cache first const cachedValue = appConfig.data.get("android-application-catalog-plugin"); if (cachedValue) { return cachedValue; } logger.debug(`[SOURCE] Looking for android.application plugin in gradle TOML files in ${projectRoot}`); // Find gradle TOML files const tomlFiles = await glob(`gradle/**/*.toml`, { cwd: projectRoot, ignore: ["**/build/**", "**/node_modules/**"] }); logger.debug(`[SOURCE] Found ${tomlFiles.length} TOML files: ${tomlFiles.join(", ")}`); for (const tomlFile of tomlFiles) { const fullPath = path.join(projectRoot, tomlFile); try { const content = await fs.readFile(fullPath, "utf8"); // Look for android.application plugin definition // Pattern: android-application = { id = "com.android.application", version = "..." } // or: android-application = "com.android.application:version" // Also handle: android.application = { id = "com.android.application", version = "..." } const pluginMatch = content.match(/([a-zA-Z0-9-._]+)\s*=\s*\{\s*id\s*=\s*["']com\.android\.application["']/) || content.match(/([a-zA-Z0-9-._]+)\s*=\s*["']com\.android\.application["']/); if (pluginMatch) { const pluginName = `libs.plugins.${pluginMatch[1].replaceAll("-", ".")}`; logger.debug(`[SOURCE] Found android.application plugin as: ${pluginName}`); await ConfigurationManager.getInstance().setAndroidAppDataKey(appConfig.appId, "android-application-catalog-plugin", pluginName); return pluginName; } } catch (error) { logger.warn(`Failed to read TOML file ${fullPath}: ${error}`); } } return null; } // =========================================== // Module Discovery // =========================================== /** * Parse gradle TOML files to find the android application plugin convention definitions. * * This implementation follows the general steps of the bash script: * 1. Discover included builds via settings.gradle (includeBuild). * 2. Search for registered plugins in included builds (usually in build.gradle files). * 3. For each registered plugin, find the implementation class, and check if it configures ApplicationExtension. */ private async findAndroidApplicationConventionPlugin(appConfig: AppConfig, projectRoot: string, catalogIdentifier: string | null): Promise<string[]> { // Check cache first const cachedValue = appConfig.data.get("android-application-convention-plugin")?.split("|") || []; if (cachedValue.length > 0 && !(cachedValue.length === 1 && cachedValue[0] === "")) { return cachedValue; } logger.debug(`[SOURCE] Looking for included build in settings...`); // Step 1: Discover included builds via settings.gradle/settings.gradle.kts const settingsFiles = await glob("**/settings.gradle{,.kts}", { cwd: projectRoot, ignore: ["**/build/**", "**/node_modules/**"] }); const includedBuilds: string[] = []; for (const settingsFile of settingsFiles) { const fullPath = path.join(projectRoot, settingsFile); try { const content = await fs.readFile(fullPath, "utf8"); // Look for includeBuild statements (match both function and bare) const includeBuildPattern = /includeBuild\s*\(\s*['"]([^'"]+)['"]\s*\)|includeBuild\s+['"]([^'"]+)['"]/g; let match: RegExpExecArray | null = null; while ((match = includeBuildPattern.exec(content)) !== null) { const includePath = match[1] || match[2]; if (includePath) { const absoluteIncludePath = path.resolve(path.dirname(fullPath), includePath); includedBuilds.push(absoluteIncludePath); logger.debug(`[SOURCE] Found included build: ${absoluteIncludePath}`); } } } catch (error) { logger.warn(`Failed to read settings file ${fullPath}: ${error}`); } } if (includedBuilds.length === 0) { logger.debug("[SOURCE] No included builds found."); return []; } const conventionPlugins: string[] = []; // Step 2: For each included build, check for registered plugins & their implementation classes logger.debug(`[SOURCE] Finding registered plugin IDs in included builds...`); for (const includedBuild of includedBuilds) { logger.debug(`[SOURCE] Analyzing build: ${includedBuild}`); // Usually the plugin registrations are in buildSrc/build.gradle.kts or build.gradle, or in conventional plugin modules // Simplistic approach: look at every .gradle or .gradle.kts in the included build, searching for "register("PLUGIN_ID")" const gradleFiles = await glob("**/*.gradle{,.kts}", { cwd: includedBuild, ignore: ["**/build/**", "**/node_modules/**"] }); type PluginReg = { pluginId: string, implementationClass: string, buildFile: string }; const pluginRegistrations: PluginReg[] = []; for (const gradleFile of gradleFiles) { const fullPath = path.join(includedBuild, gradleFile); try { const content = await fs.readFile(fullPath, "utf8"); // Look for: register("my.plugin.id") { implementationClass = "com.foo.SomeClass" } // const registerMatches = content.matchAll(/register\s*\(\s*"([^"]+)"\s*\)\s*\{([^}]+)\}/g); const registerRegex = /register\s*\(\s*["']([^"']+)["']\s*\)\s*\{([\s\S]*?)}\s*\n?/g; let match: RegExpExecArray | null; while ((match = registerRegex.exec(content)) !== null) { const pluginId = match[1]; const blockContent = match[2] || ""; // Try to find implementationClass = "...", single or double quotes const implClassRegex = /implementationClass\s*=\s*["']([^"']+)["']/; const implClassMatch = blockContent.match(implClassRegex); if (implClassMatch) { const implementationClass = implClassMatch[1]; pluginRegistrations.push({ pluginId, implementationClass, buildFile: fullPath }); logger.debug(`[SOURCE] Found plugin registration: ${pluginId} -> ${implementationClass}`); } } } catch (error) { logger.warn(`Failed to read gradle file ${fullPath}: ${error}`); } } // Step 3: For each plugin registration, find the class file and check for ApplicationExtension references logger.debug(`[SOURCE] Analyzing implementation classes for Application plugin usage...`); for (const registration of pluginRegistrations) { const { pluginId, implementationClass, buildFile } = registration; logger.debug(`[SOURCE] Checking ${pluginId} (${implementationClass})...`); // Split to get file/class names const className = implementationClass.split(".").pop() || ""; const expectedPackage = implementationClass.split(".").slice(0, -1).join("."); // Try to find this class in src/* (src/main/kotlin, src/main/java, etc.) const buildModuleDir = path.dirname(buildFile); let implFile = ""; // Try src/main/kotlin and src/main/java and src/ (flattened for multiplatform) const candidateSrcDirs = [ path.join(buildModuleDir, "src", "main", "kotlin"), path.join(buildModuleDir, "src", "main", "java"), path.join(buildModuleDir, "src") ]; // Keep track if we found the file let found = false; for (const srcDir of candidateSrcDirs) { if (!require("fs").existsSync(srcDir)) {continue;} const kotlinFiles = await glob("**/*.kt", { cwd: srcDir }); for (const kotlinFile of kotlinFiles) { const potentialFile = path.join(srcDir, kotlinFile); try { const fileContent = await fs.readFile(potentialFile, "utf8"); // Check if file contains correct class and correct package const packageMatch = fileContent.match(/^package\s+([^\s;]+)/m); const classMatch = fileContent.match(new RegExp(`class\\s+${className}\\b`)); if (packageMatch && classMatch && packageMatch[1] === expectedPackage) { implFile = potentialFile; found = true; break; } } catch (error) { logger.warn(`Failed to read potential implementation file ${potentialFile}: ${error}`); } } if (found) {break;} } if (implFile && require("fs").existsSync(implFile)) { logger.debug(`[SOURCE] Found implementation file: ${implFile}`); try { const fileContent = await fs.readFile(implFile, "utf8"); // Check for ApplicationExtension or ApplicationAndroidComponentsExtension, which marks this as an app plugin if ( fileContent.match(/ApplicationExtension\b/) || fileContent.match(/ApplicationAndroidComponentsExtension\b/) ) { logger.debug(`[SOURCE] ✅ FOUND APPLICATION PLUGIN: ${pluginId}`); conventionPlugins.push(pluginId); } } catch (error) { logger.warn(`Failed to read implementation file ${implFile}: ${error}`); } } else { logger.warn(`[SOURCE] ⚠️ Implementation file not found for class: ${className}`); } } } logger.debug(`[SOURCE] Found ${conventionPlugins.length} application convention plugins: ${conventionPlugins.join(", ")}`); // Cache the result if found if (conventionPlugins.length > 0) { await ConfigurationManager.getInstance().setAndroidAppDataKey(appConfig.appId, "android-application-convention-plugin", conventionPlugins.join("|")); } return conventionPlugins; } /** * Check if a build.gradle file represents an Android module (library or application) */ private async isAndroidModule(buildGradlePath: string, androidConventionPlugins: string[]): Promise<boolean> { try { const content = await fs.readFile(buildGradlePath, "utf8"); const hasAndroidBlock = content.includes("android {"); const appliedAndroidPlugins = content.includes("apply plugin: 'com.android") || content.includes('id("com.android') || content.includes('apply(plugin = "com.android'); const appliedConventionPlugins = androidConventionPlugins.some(plugin => content.includes(plugin)); logger.debug(`[SOURCE] Checking if ${buildGradlePath} is an Android module... hasAndroidBlock: ${hasAndroidBlock}`); return hasAndroidBlock || appliedAndroidPlugins || appliedConventionPlugins; // TODO: add content checks for convention plugins that would add any of the above } catch (error) { logger.error(`Error reading build.gradle file: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } return false; } } private async isJvmModule(buildGradlePath: string): Promise<boolean> { try { const content = await fs.readFile(buildGradlePath, "utf8"); const hasJvmPlugin = content.includes('id("jvm'); logger.debug(`[SOURCE] Checking if ${buildGradlePath} is an Android module... hasJvmPlugin: ${hasJvmPlugin}`); return hasJvmPlugin; } catch (error) { logger.error(`Error reading build.gradle file: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } return false; } } /** * Check if a build.gradle file represents an Android application module */ private async isAndroidApplicationModule(buildGradlePath: string, androidApplicationIdentifiers: string[]): Promise<boolean> { try { const content = await fs.readFile(buildGradlePath, "utf8"); logger.debug(`Application module at ${buildGradlePath}?`); const pluginPatterns = androidApplicationIdentifiers.flatMap(pluginName => [ new RegExp(`\\s*(${pluginName})\\s*`), ]); for (const pattern of pluginPatterns) { if (pattern.test(content)) { logger.debug(`Application plugin found in ${buildGradlePath}`); return true; } } logger.debug(`No application plugin found in ${buildGradlePath}`); return false; } catch (error) { logger.error(`Error reading build.gradle file: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } return false; } } /** * Discover all Android modules in a project */ public async scanProject(applicationId: string): Promise<ProjectScanResult> { let startTime = Date.now(); const appConfig = this.getMatchingAppConfig(applicationId); if (!appConfig || !appConfig.sourceDir) { throw new ActionableError(`Unable to scan application source without a path`); } const projectRoot = appConfig.sourceDir; // Check cache first if (this.projectScanResultCache.has(projectRoot)) { return this.projectScanResultCache.get(projectRoot)!; } logger.debug(`[SOURCE] Discovering Android modules in: ${projectRoot}`); // Find the android.application plugin definition const androidApplicationIdentifiers = await this.findAndroidApplicationIdentifiers(applicationId, projectRoot); if (!androidApplicationIdentifiers) { throw new ActionableError(`Unable to find android.application plugin in ${projectRoot}`); } logger.debug(`[SOURCE] Found android.application identifiers in ${Date.now() - startTime}ms`); startTime = Date.now(); const { plugins: gradlePlugins, dependencies: mavenDependencies } = await this.readGradleTomlFiles(projectRoot); logger.debug(`[SOURCE] Read Gradle TOML files in ${Date.now() - startTime}ms`); logger.debug(`[SOURCE] Found ${gradlePlugins.length} Gradle plugins and ${mavenDependencies.length} Maven dependencies`); let primaryAppModule: ModuleMapping | undefined; const modules: ModuleMapping[] = []; // Find all build.gradle files that indicate modules const buildGradleFiles = await glob("**/build.gradle{,.kts}", { cwd: projectRoot, ignore: ["**/build/**", "**/node_modules/**"] }); logger.debug(`[SOURCE] Found ${buildGradleFiles.length} build.gradle files`); startTime = Date.now(); for (const buildGradlePath of buildGradleFiles) { const moduleDir = path.dirname(buildGradlePath); const moduleName = path.basename(moduleDir); // Skip root build.gradle if (moduleDir === "." || moduleName === path.basename(projectRoot)) { continue; } const fullModulePath = path.join(projectRoot, moduleDir); const fullBuildGradlePath = path.join(projectRoot, buildGradlePath); // Check if this is an Android module by looking for android block const isAndroidModule = await this.isAndroidModule(fullBuildGradlePath, androidApplicationIdentifiers); // const isJvmModule = !isAndroidModule && await this.isJvmModule(fullBuildGradlePath); // Discover source and test directories const sourceMainJavaDirectory = path.join(fullModulePath, "src", "main", "java"); const sourceMainKotlinDirectory = path.join(fullModulePath, "src", "main", "kotlin"); const testDirectory = path.join(fullModulePath, "src", "test"); // Extract package prefix from source files let packagePrefix: string; let kotlinSource: boolean = false; let javaSource: boolean = false; const javaPackagePrefix = await this.extractPackagePrefix(sourceMainJavaDirectory); const kotlinPackagePrefix = await this.extractPackagePrefix(sourceMainKotlinDirectory); if (kotlinPackagePrefix && javaPackagePrefix) { packagePrefix = kotlinPackagePrefix; kotlinSource = true; javaSource = true; } else if (kotlinPackagePrefix) { packagePrefix = kotlinPackagePrefix; kotlinSource = true; } else if (javaPackagePrefix) { packagePrefix = javaPackagePrefix; javaSource = true; } else { packagePrefix = ""; } let isApplicationModule: boolean = false; let activities: string[] = []; let fragments: string[] = []; if (isAndroidModule) { // Check if this is an application module isApplicationModule = await this.isAndroidApplicationModule(fullBuildGradlePath, androidApplicationIdentifiers); // Find activities and fragments const activityFragmentResult = await this.findActivitiesAndFragments(sourceMainJavaDirectory); activities = activityFragmentResult.activities; fragments = activityFragmentResult.fragments; } const moduleMapping: ModuleMapping = { fullModulePath, sourceDirectory: sourceMainJavaDirectory, testDirectory, packagePrefix, activities, fragments, buildGradlePath: fullBuildGradlePath, isApplicationModule, kotlinSource, javaSource }; if (isApplicationModule) { const moduleApplicationId = await this.getApplicationId(fullBuildGradlePath); logger.debug(`[SOURCE] Module: ${moduleName}, gradleBuildPath: ${fullBuildGradlePath}, Application ID: ${moduleApplicationId}`); if (moduleApplicationId === applicationId) { primaryAppModule = moduleMapping; } } modules.push(moduleMapping); const moduleType = isApplicationModule ? "application" : "library"; logger.debug(`[SOURCE] Discovered ${moduleType} module: ${moduleName} with ${activities.length} activities, ${fragments.length} fragments, Package prefix: ${packagePrefix}`); } logger.debug(`[SOURCE] Found and parsed ${modules.length} modules in ${Date.now() - startTime}ms`); logger.debug(`[SOURCE] Project root: ${projectRoot}`); const applicationModules = modules.filter(m => m.isApplicationModule); logger.debug(`[SOURCE] Discovered ${modules.length} Android modules (${applicationModules.length} application modules)`); if (!applicationModules || applicationModules.length === 0) { throw new ActionableError("No Android application modules found in the project."); } logger.debug(`[SOURCE] Starting to look for primary app module with applicationId: ${applicationId}`); startTime = Date.now(); // Find the application module that matches the provided applicationId for (const module of applicationModules) { if (module.buildGradlePath) { const moduleApplicationId = await this.getApplicationId(module.buildGradlePath); logger.debug(`[SOURCE] Module: ${module.fullModulePath}, gradleBuildPath: ${module.buildGradlePath}, Application ID: ${moduleApplicationId}`); if (moduleApplicationId === applicationId) { primaryAppModule = module; } } } if (!primaryAppModule) { throw new ActionableError("Specified Android applicationId not found in project modules."); } logger.debug(`[SOURCE] Found primary app module in ${Date.now() - startTime}ms: ${primaryAppModule.fullModulePath}`); // Get details for the primary application module let currentApplicationModule: ApplicationModuleDetails | undefined; if (primaryAppModule?.buildGradlePath) { const modulePath = path.relative(projectRoot, path.dirname(primaryAppModule.buildGradlePath)); const absolutePath = path.join(projectRoot, modulePath); const moduleApplicationId = await this.getApplicationId(primaryAppModule.buildGradlePath); startTime = Date.now(); logger.debug(`[SOURCE] Primary application module: ${primaryAppModule.fullModulePath}, Application ID: ${moduleApplicationId}`); if (moduleApplicationId) { currentApplicationModule = { absolutePath, applicationId: moduleApplicationId, }; } } const result: ProjectScanResult = { modules, applicationModules, totalModules: modules.length, gradlePlugins, mavenDependencies, currentApplicationModule }; // Cache the results this.projectScanResultCache.set(projectRoot, result); return result; } /** * Extract package prefix from source directory */ private async extractPackagePrefix(sourceDirectory: string): Promise<string> { try { // Look for the first Java/Kotlin file and extract its package const javaFiles = await glob("**/*.{java,kt}", { cwd: sourceDirectory }); // Only check first few files to avoid performance issues const filesToCheck = javaFiles.slice(0, 5); for (const javaFile of filesToCheck) { const filePath = path.join(sourceDirectory, javaFile); const content = await fs.readFile(filePath, "utf8"); const packageMatch = content.match(/package\s+([a-zA-Z][a-zA-Z0-9._]*)/); if (packageMatch) { return packageMatch[1]; } } } catch (error) { logger.debug(`[SOURCE] Failed to extract package prefix: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } } return ""; } /** * Find activities and fragments in source directory */ private async findActivitiesAndFragments(sourceDirectory: string): Promise<{ activities: string[]; fragments: string[]; }> { const activities: string[] = []; const fragments: string[] = []; try { // Find all Java/Kotlin files const sourceFiles = await glob("**/*.{java,kt}", { cwd: sourceDirectory }); for (const sourceFile of sourceFiles) { const filePath = path.join(sourceDirectory, sourceFile); const content = await fs.readFile(filePath, "utf8"); // Extract package and class name const packageMatch = content.match(/package\s+([a-zA-Z][a-zA-Z0-9._]*)/); const classMatch = content.match(/(?:class|object)\s+(\w+)/); if (packageMatch && classMatch) { const fullClassName = `${packageMatch[1]}.${classMatch[1]}`; // Check if it's an Activity if (content.includes("Activity") && (sourceFile.includes("Activity") || content.includes("extends.*Activity") || content.includes(": .*Activity"))) { activities.push(fullClassName); } // Check if it's a Fragment if (content.includes("Fragment") && (sourceFile.includes("Fragment") || content.includes("extends.*Fragment") || content.includes(": .*Fragment"))) { fragments.push(fullClassName); } } } } catch (error) { logger.debug(`[SOURCE] Failed to find activities and fragments: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } } return { activities, fragments }; } /** * Read gradle TOML files to find plugins and dependencies */ private async readGradleTomlFiles(projectRoot: string): Promise<{ plugins: string[]; dependencies: string[]; }> { const plugins = new Set<string>(); const dependencies = new Set<string>(); try { const tomlFiles = await glob("gradle/**/*.toml", { cwd: projectRoot, ignore: ["**/build/**", "**/node_modules/**"] }); for (const tomlFile of tomlFiles) { const fullPath = path.join(projectRoot, tomlFile); const content = await fs.readFile(fullPath, "utf8"); // Look for [plugins] section const pluginsMatch = content.match(/\[plugins\]\s*\n([\s\S]*?)(?=\n\s*\[|$)/); if (pluginsMatch) { const pluginBlock = pluginsMatch[1]; // Regex to find plugin aliases: alias = { id = "...", version = "..." } or alias = "..." const pluginAliases = pluginBlock.matchAll(/^\s*([a-zA-Z0-9-._]+)\s*=/gm); for (const match of pluginAliases) { plugins.add(match[1]); } } // Look for [libraries] section (which corresponds to dependencies) const librariesMatch = content.match(/\[libraries\]\s*\n([\s\S]*?)(?=\n\s*\[|$)/); if (librariesMatch) { const libraryBlock = librariesMatch[1]; // Regex to find library aliases: alias = { module = "...", version.ref = "..." } or "group:artifact:version" const libraryAliases = libraryBlock.matchAll(/^\s*([a-zA-Z0-9-._]+)\s*=/gm); for (const match of libraryAliases) { dependencies.add(match[1]); } } } } catch (error) { logger.warn(`Failed to read Gradle TOML files: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } } return { plugins: Array.from(plugins), dependencies: Array.from(dependencies) }; } /** * Get application ID from build.gradle file */ private async getApplicationId(buildGradlePath: string): Promise<string | null> { try { const content = await fs.readFile(buildGradlePath, "utf8"); // For build.gradle.kts (Kotlin DSL) - various formats let match = content.match(/applicationId\s*=\s*"([^"]+)"/); if (match) { return match[1]; } // Kotlin DSL with single quotes match = content.match(/applicationId\s*=\s*'([^']+)'/); if (match) { return match[1]; } // For build.gradle (Groovy DSL) - various formats match = content.match(/applicationId\s+"([^"]+)"/); if (match) { return match[1]; } // Groovy DSL with single quotes match = content.match(/applicationId\s+'([^']+)'/); if (match) { return match[1]; } // Alternative format with equals sign in Groovy match = content.match(/applicationId\s*=\s*"([^"]+)"/); if (match) { return match[1]; } // Alternative format with equals sign and single quotes in Groovy match = content.match(/applicationId\s*=\s*'([^']+)'/); if (match) { return match[1]; } } catch (error) { logger.warn(`Failed to read build.gradle to get applicationId for ${buildGradlePath}: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } } return null; } /** * Get list of gradle tasks for a module */ private async getGradleTasks(projectRoot: string, modulePath: string): Promise<string[]> { const gradleModulePath = `:${modulePath.replace(/[\\\\/]/g, ":")}`; const command = `./gradlew ${gradleModulePath}:tasks --all`; return new Promise(resolve => { const gradlew = spawn(command, [], { cwd: projectRoot, shell: true }); let output = ""; let errorOutput = ""; const timer = setTimeout(() => { gradlew.kill(); logger.warn("Gradle tasks command timed out"); resolve([]); }, 60000); // 30s timeout gradlew.stdout.on("data", data => { output += data.toString(); }); gradlew.stderr.on("data", data => { errorOutput += data.toString(); }); gradlew.on("close", code => { clearTimeout(timer); if (code === 0) { const tasks = output.split("\n") .map(line => line.trim()) .filter(line => /^[a-zA-Z0-9]/.test(line) && line.includes(" - ")) .map(line => line.split(" - ")[0]); resolve(tasks); } else { logger.warn(`'${command}' failed with code ${code}: ${errorOutput}`); resolve([]); } }); gradlew.on("error", err => { clearTimeout(timer); logger.warn(`Failed to run '${command}': ${err.message}`); resolve([]); }); }); } // =========================================== // View Hierarchy Analysis // =========================================== /** * Analyze view hierarchy to extract source mapping information */ public analyzeViewHierarchy(appId: string, viewHierarchy: ViewHierarchyResult): ViewHierarchyAnalysis { const analysis: ViewHierarchyAnalysis = { appId: appId, activityClasses: [], fragmentClasses: [], resourceIds: [], customViews: [], }; try { if (!viewHierarchy || !viewHierarchy.hierarchy || !viewHierarchy.hierarchy.node) { return analysis; } // Extract activity class names (these would come from current activity info) // For now, we'll collect any class names that end with "Activity" const activityClasses = new Set<string>(); const fragmentClasses = new Set<string>(); const resourceIds = new Set<string>(); const customViews = new Set<string>(); // Traverse the hierarchy to collect information const traverseNode = (node: any): void => { if (!node) { return; } // Extract resource IDs const resourceId = node["resource-id"] || node.resourceId; if (resourceId) { resourceIds.add(resourceId); } // Extract class names const className = node["class"] || node.className; if (className) { if (className.includes("Activity")) { activityClasses.add(className); } else if (className.includes("Fragment")) { fragmentClasses.add(className); } else if ( !className.startsWith("android.") && !className.startsWith("com.android.") && !className.startsWith("androidx.") ) { // Custom view classes (not Android framework classes) customViews.add(className); } } // Extract fragment information from augmented data const fragmentName = node["fragment"]; if (fragmentName) { fragmentClasses.add(fragmentName); } // Extract custom view information from augmented data const customView = node["customView"]; if (customView) { customViews.add(customView); } // Recursively traverse child nodes if (node.node) { const children = Array.isArray(node.node) ? node.node : [node.node]; for (const child of children) { traverseNode(child); } } }; // Start traversal from the root node traverseNode(viewHierarchy.hierarchy.node); // Convert sets to arrays analysis.activityClasses = Array.from(activityClasses); analysis.fragmentClasses = Array.from(fragmentClasses); analysis.resourceIds = Array.from(resourceIds); analysis.customViews = Array.from(customViews); logger.debug(`[SOURCE] View hierarchy analysis found: ${analysis.activityClasses.length} activities, ${analysis.fragmentClasses.length} fragments, ${analysis.resourceIds.length} resource IDs, ${analysis.customViews.length} custom views`); } catch (error) { logger.warn(`Failed to analyze view hierarchy: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } } return analysis; } /** * Extract package hints from class names and resource IDs */ private extractPackageHints(classNames: string[], resourceIds: string[]): string[] { const packageHints = new Set<string>(); // Extract from class names for (const className of classNames) { const parts = className.split("."); if (parts.length >= 3) { // Add various package combinations packageHints.add(parts.slice(0, -1).join(".")); // Full package packageHints.add(parts.slice(0, -2).join(".")); // Parent package if (parts.length >= 4) { packageHints.add(parts.slice(0, 3).join(".")); // Base package } } } // Extract from resource IDs (e.g., com.example.app:id/button) for (const resourceId of resourceIds) { const parts = resourceId.split(":"); if (parts.length >= 2) { packageHints.add(parts[0]); } } return Array.from(packageHints); } // =========================================== // Test Plan Placement // =========================================== /** * Determine the best location for a test plan */ public async determineTestPlanLocation( analysis: ViewHierarchyAnalysis, applicationId: string ): Promise<TestPlanPlacementResult> { const moduleDiscovery = await this.scanProject(applicationId); const modules = moduleDiscovery.modules; let bestMatch: ModuleMapping | undefined; let confidence = 0; const reasoningParts: string[] = []; // Score each module based on the analysis for (const module of modules) { let moduleScore = 0; const moduleReasons: string[] = []; // Check activity matches for (const activity of analysis.activityClasses) { if (module.activities.some(a => a.includes(activity) || activity.includes(a))) { logger.debug(`[SOURCE] Activity match: ${activity} on module ${module.fullModulePath}`); moduleScore += 50; // High weight for activity matches moduleReasons.push(`activity match: ${activity}`); } } // Check fragment matches for (const fragment of analysis.fragmentClasses) { if (module.fragments.some(f => f.includes(fragment) || fragment.includes(f))) { logger.debug(`[SOURCE] Fragment match: ${fragment} on module ${module.fullModulePath}`); moduleScore += 80; // Medium weight for fragment matches moduleReasons.push(`fragment match: ${fragment}`); } } if (module.fullModulePath === moduleDiscovery.currentApplicationModule?.absolutePath) { moduleScore += 30; // Current main app module moduleReasons.push("fallback to application module"); } else if (module.isApplicationModule) { moduleScore += 10; // some other app module moduleReasons.push("fallback to application module"); } logger.debug(`[SOURCE] Module ${module.fullModulePath} (package: ${module.packagePrefix}, isApp: ${module.isApplicationModule}): score=${moduleScore}, reasons=${moduleReasons.join(", ")}`); if (moduleScore > confidence) { confidence = moduleScore; logger.debug(`[SOURCE] New best match: ${module.fullModulePath} with score ${moduleScore}`); bestMatch = module; reasoningParts.length = 0; reasoningParts.push(...moduleReasons); } } // Normalize confidence to 0-1 scale const normalizedConfidence = Math.min(confidence / 100, 1); const sourceAnalysis: SourceAnalysis = { primaryActivity: analysis.activityClasses[0], fragments: analysis.fragmentClasses, confidence: normalizedConfidence, suggestedModule: bestMatch?.fullModulePath, resourceReferences: analysis.resourceIds }; logger.debug(`[SOURCE] Source analysis completed: module=${bestMatch?.fullModulePath}, confidence=${normalizedConfidence.toFixed(2)}`); // Find the suggested module or fallback to first application module let targetModule = modules.find(m => m.fullModulePath === sourceAnalysis.suggestedModule); if (!targetModule) { targetModule = moduleDiscovery.applicationModules?.[0] || modules[0]; } if (!targetModule) { throw new ActionableError("No target module found for source analysis"); } logger.debug(`[SOURCE] Target module: ${targetModule.fullModulePath} (${targetModule.testDirectory}`); // Ensure test plan directory exists const testPlanDir = path.join(targetModule.testDirectory, "resources", "test-plans"); await fs.mkdir(testPlanDir, { recursive: true }); return { success: true, targetDirectory: testPlanDir, moduleName: targetModule.fullModulePath, confidence: sourceAnalysis.confidence, reasoning: `Selected module '${targetModule.fullModulePath}' based on source analysis` }; } // =========================================== // Source File Indexing // =========================================== /** * Execute ripgrep search with timeout */ private async executeRipgrep(pattern: string, directory: string, timeout: number = 10000, usePcre2: boolean = false): Promise<string[]> { return new Promise((resolve, reject) => { const args = [ "--type", "java", "--type", "kotlin", "--files-with-matches", "--no-heading", "--case-sensitive" ]; if (usePcre2) { args.push("--pcre2"); } args.push(pattern, directory); const rg = spawn("rg", args); let output = ""; let errorOutput = ""; const timer = setTimeout(() => { rg.kill(); reject(new Error(`Ripgrep search timed out after ${timeout}ms`)); }, timeout); rg.stdout.on("data", data => { output += data.toString(); }); rg.stderr.on("data", data => { errorOutput += data.toString(); }); rg.on("close", code => { clearTimeout(timer); if (code === 0) { const files = output.trim().split("\n").filter(line => line.length > 0); resolve(files); } else if (code === 1) { // No matches found - this is normal resolve([]); } else { reject(new Error(`Ripgrep failed with code ${code}: ${errorOutput}`)); } }); rg.on("error", error => { clearTimeout(timer); reject(error); }); }); } /** * Determine which source file to prioritize based on build variant */ private prioritizeSourceFile(files: string[]): string { if (files.length === 1) { return files[0]; } // Priority order for build variants (debug is most important) const variantPriority = ["debug", "main", "release"]; for (const variant of variantPriority) { const variantFile = files.find(file => file.includes(`src/${variant}/`)); if (variantFile) { return variantFile; } } // If no specific variant found, return the first file return files[0]; } /** * Index source files for activities, fragments, and views */ async indexSourceFiles(appId: string, sourceDir: string): Promise<SourceIndexResult> { const activities = new Map<string, ActivityInfo>(); const fragments = new Map<string, FragmentInfo>(); const views = new Map<string, ViewInfo>(); const composables = new Map<string, ComposableInfo>(); logger.info(`[SOURCE] Starting source indexing for app: ${appId}`); try { // Find all Java/Kotlin files that contain "Activity" class definitions const activityPattern = "class\\s+\\w*Activity"; const activityFiles = await this.executeRipgrep(activityPattern, sourceDir); for (const file of activityFiles) { try { const content = await fs.readFile(file, "utf8"); const packageMatch = content.match(/package\s+([a-zA-Z][a-zA-Z0-9._]*)/); const classMatch = content.match(/class\s+(\w*Activity)/); if (packageMatch && classMatch) { const packageName = packageMatch[1]; const className = classMatch[1]; const fullClassName = `${packageName}.${className}`; activities.set(fullClassName, { className, packageName, fullClassName, sourceFile: file }); logger.debug(`[SOURCE] Indexed activity: ${fullClassName} -> ${file}`); } } catch (error) { logger.warn(`Failed to process activity file ${file}: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } } } // Find all Java/Kotlin files that contain "Fragment" class definitions const fragmentPattern = "class\\s+\\w*Fragment"; const fragmentFiles = await this.executeRipgrep(fragmentPattern, sourceDir); for (const file of fragmentFiles) { try { const content = await fs.readFile(file, "utf8"); const packageMatch = content.match(/package\s+([a-zA-Z][a-zA-Z0-9._]*)/); const classMatch = content.match(/class\s+(\w*Fragment)/); if (packageMatch && classMatch) { const packageName = packageMatch[1]; const className = classMatch[1]; const fullClassName = `${packageName}.${className}`; fragments.set(fullClassName, { className, packageName, fullClassName, sourceFile: file }); logger.debug(`[SOURCE] Indexed fragment: ${fullClassName} -> ${file}`); } } catch (error) { logger.warn(`Failed to process fragment file ${file}: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } } } // Find all Java/Kotlin files that contain custom View class definitions // This pattern specifically looks for classes that extend Android View classes // But excludes ViewModel classes by ensuring the parent class is not ViewModel const viewPattern = "class\\s+\\w+\\s+extends\\s+\\w*View(?!Model)|class\\s+\\w+\\s*:\\s*\\w*View(?!Model)"; const viewFiles = await this.executeRipgrep(viewPattern, sourceDir, 10000, true); for (const file of viewFiles) { try { const content = await fs.readFile(file, "utf8"); const packageMatch = content.match(/package\s+([a-zA-Z][a-zA-Z0-9._]*)/); // Only match classes that actually extend View classes, not just any class ending with "View" // Java: class CustomView extends View // Kotlin: class CustomView : View const javaViewMatch = content.match(/class\s+(\w+)\s+extends\s+\w*View/); const kotlinViewMatch = content.match(/class\s+(\w+)\s*:\s*\w*View/); const classMatch = javaViewMatch || kotlinViewMatch; if (packageMatch && classMatch) { const packageName = packageMatch[1]; const className = classMatch[1]; const fullClassName = `${packageName}.${className}`; views.set(fullClassName, { className, packageName, fullClassName, sourceFile: file }); logger.info(`[SOURCE] Indexed view: ${fullClassName} -> ${file}`); } } catch (error) { logger.warn(`Failed to process view file ${file}: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } } } // Find all Kotlin files that contain @Composable functions (excluding @Preview) const composablePattern = "@Composable"; const composableFiles = await this.executeRipgrep(composablePattern, sourceDir); for (const file of composableFiles) { try { const content = await fs.readFile(file, "utf8"); const packageMatch = content.match(/package\s+([a-zA-Z][a-zA-Z0-9._]*)/); if (packageMatch) { const packageName = packageMatch[1]; // Find all @Composable functions in this file, excluding @Preview functions const composableMatches = content.matchAll(/@Composable(?:\s+(?:@\w+\s*)*(?:inline\s+|private\s+|internal\s+|public\s+)*)?fun\s+([a-zA-Z_][a-zA-Z0-9_]*)/g); for (const match of composableMatches) { const functionName = match[1]; // Skip if this is a preview function (look for @Preview annotation before @Composable) const functionStartIndex = match.index || 0; const beforeFunction = content.substring(Math.max(0, functionStartIndex - 500), functionStartIndex); if (beforeFunction.includes("@Preview")) { continue; } // Also skip functions that start with "Preview" by naming convention if (functionName.startsWith("Preview")) { continue; } const fullClassName = `${packageName}.${functionName}`; composables.set(fullClassName, { className: functionName, packageName, fullClassName, sourceFile: file }); logger.debug(`[SOURCE] Indexed composable: ${fullClassName} -> ${file}`); } } } catch (error) { logger.warn(`Failed to process composable file ${file}: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } } } logger.debug(`[SOURCE] Source indexing completed: ${activities.size} activities, ${fragments.size} fragments, ${views.size} views, ${composables.size} composables`); } catch (error) { console.error(`Error during source indexing: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } } return { activities, fragments, views, composables, lastIndexed: Date.now() }; } /** * Get or create source index for an app */ public async getSourceIndex(appId: string): Promise<SourceIndexResult | null> { const sourceDir = this.getSourceDir(appId); if (!sourceDir) { logger.warn(`No source directory configured for app: ${appId}`); return null; } // Check if we have a cached index let sourceIndex: SourceIndexResult | undefined = this.sourceIndex.get(appId); const cacheFile = path.join(this.sourceCacheDir, `${appId}-index.json`); // Try to load from disk cache if not in memory if (!sourceIndex && require("fs").existsSync(cacheFile)) { const loadedIndex = await this.loadSourceIndexFromDisk(cacheFile, appId); if (loadedIndex) { sourceIndex = loadedIndex; } } // Check if index is stale (older than 1 hour) const indexAge = sourceIndex ? Date.now() - sourceIndex.lastIndexed : Infinity; const maxAge = 60 * 60 * 1000; // 1 hour if (!sourceIndex || indexAge > maxAge) { logger.debug(`[SOURCE] Creating fresh source index for app: ${appId}`); sourceIndex = await this.createFreshSourceIndex(appId, sourceDir, cacheFile); } return sourceIndex; } /** * Load source index from disk cache */ private async loadSourceIndexFromDisk(cacheFile: string, appId: string): Promise<SourceIndexResult | null> { try { const cacheData = await fs.readFile(cacheFile, "utf8"); const parsedData = JSON.parse(cacheData); // Convert plain objects back to Maps with proper typing const sourceIndex: SourceIndexResult = { activities: new Map(Object.entries(parsedData.activities as Record<string, ActivityInfo>)), fragments: new Map(Object.entries(parsedData.fragments as Record<string, FragmentInfo>)), views: new Map(Object.entries(parsedData.views as Record<string, ViewInfo>)), composables: new Map(Object.entries(parsedData.composables as Record<string, ComposableInfo>)), lastIndexed: parsedData.lastIndexed as number }; this.sourceIndex.set(appId, sourceIndex); logger.debug(`[SOURCE] Loaded source index from cache for app: ${appId}`); return sourceIndex; } catch (error) { logger.warn(`Failed to load source index cache: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } return null; } } /** * Create a fresh source index and save to cache */ private async createFreshSourceIndex(appId: string, sourceDir: string, cacheFile: string): Promise<SourceIndexResult> { try { const sourceIndex = await this.indexSourceFiles(appId, sourceDir); this.sourceIndex.set(appId, sourceIndex); // Save to disk cache (don't fail the whole operation if this fails) await this.saveSourceIndexToDisk(sourceIndex, cacheFile, appId); return sourceIndex; } catch (error) { logger.warn(`Failed to index source files for app ${appId}: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } // Return a default structure even if indexing fails const defaultIndex = { activities: new Map(), fragments: new Map(), views: new Map(), composables: new Map(), lastIndexed: Date.now() }; this.sourceIndex.set(appId, defaultIndex); return defaultIndex; } } /** * Save source index to disk cache */ private async saveSourceIndexToDisk(sourceIndex: SourceIndexResult, cacheFile: string, appId: string): Promise<void> { try { const cacheData = { activities: Object.fromEntries(sourceIndex.activities), fragments: Object.fromEntries(sourceIndex.fragments), views: Object.fromEntries(sourceIndex.views), composables: Object.fromEntries(sourceIndex.composables), lastIndexed: sourceIndex.lastIndexed }; await fs.writeFile(cacheFile, JSON.stringify(cacheData, null, 2)); logger.debug(`[SOURCE] Saved source index to cache for app: ${appId}`); } catch (error) { logger.warn(`Failed to save source index cache: ${error}`); // Rethrow ActionableErrors to preserve their specific error messages if (error instanceof ActionableError) { throw error; } } } // =========================================== // Source File Finding // =========================================== /** * Find activity info by package name from view hierarchy */ public async findActivityInfo(appId: string, activityPackageName: string): Promise<ActivityInfo | null> { const sourceIndex = await this.getSourceIndex(appId); if (!sourceIndex) { return null; } // Look for exact match first const exactMatch = sourceIndex.activities.get(activityPackageName); if (exactMatch) { return exactMatch; } // Look for partial matches (in case the package name is truncated) for (const [fullClassName, activityInfo] of sourceIndex.activities) { if (fullClassName.includes(activityPackageName) || activityPackageName.includes(activityInfo.className)) { logger.debug(`[SOURCE] Found partial match for activity: ${activityPackageName} -> ${fullClassName}`); return activityInfo; } } logger.warn(`No activity found matching: ${activityPackageName}`); return null; } /** * Find fragment info by class name, with preference for fragments in same package as activity */ public async findFragmentInfo( appId: string, fragmentClassName: string, activityInfo: ActivityInfo | null ): Promise<FragmentInfo | null> { const sourceIndex = await this.getSourceIndex(appId); if (!sourceIndex) { return null; } // Look for exact match by full class name first for (const [fullClassName, fragmentInfo] of sourceIndex.fragments) { if (fullClassName.endsWith(`.${fragmentClassName}`)) { if (activityInfo) { fragmentInfo.associatedActivity = activityInfo.fullClassName; } return fragmentInfo; } } // Look for matches by class name only const matches: FragmentInfo[] = []; for (const [, fragmentInfo] of sourceIndex.fragments) { if (fragmentInfo.className === fragmentClassName) { matches.push(fragmentInfo); } } if (matches.length === 0) { logger.warn(`No fragment found matching: ${fragmentClassName}`); return null; } if (matches.length === 1) { if (activityInfo) { matches[0].associatedActivity = activityInfo.fullClassName; } return matches[0]; } // Multiple matches - prefer one in same package as activity if (activityInfo) { for (const fragmentInfo of matches) { if (fragmentInfo.packageName === activityInfo.packageName) { fragmentInfo.associatedActivity = activityInfo.fullClassName; logger.debug(`[SOURCE] Found fragment in same package as activity: ${fragmentInfo.fullClassName}`); return fragmentInfo; } } // If no exact package match, find the one with most similar package let bestMatch = matches[0]; let bestScore = 0; const activityParts = activityInfo.packageName.split("."); for (const fragmentInfo of matches) { const fragmentParts = fragmentInfo.packageName.split("."); let score = 0; const minLength = Math.min(activityParts.length, fragmentParts.length); for (let i = 0; i < minLength; i++) { if (activityParts[i] === fragmentParts[i]) { score++; } else { break; } } if (score > bestScore) { bestScore = score; bestMatch = fragmentInfo; } } bestMatch.associatedActivity = activityInfo.fullClassName; logger.debug(`[SOURCE] Found best matching fragment: ${bestMatch.fullClassName} (score: ${bestScore})`); return bestMatch; } // No activity context, return first match return matches[0]; } /** * Find view info by class name, with preference for views in same package as activity or fragment */ public async findViewInfo( appId: string, viewClassName: string, activityInfo?: ActivityInfo, fragmentInfo?: FragmentInfo ): Promise<ViewInfo | null> { const sourceIndex = await this.getSourceIndex(appId); if (!sourceIndex) { return null; } // Look for exact match by full class name first for (const [fullClassName, viewInfo] of sourceIndex.views) { if (fullClassName.endsWith(`.${viewClassName}`)) { if (activityInfo) { viewInfo.associatedActivity = activityInfo.fullClassName; } if (fragmentInfo) { viewInfo.associatedFragment = fragmentInfo.fullClassName; } return viewInfo; } } // Look for matches by class name only const matches: ViewInfo[] = []; for (const [, viewInfo] of sourceIndex.views) { if (viewInfo.className === viewClassName) { matches.push(viewInfo); } } if (matches.length === 0) { logger.warn(`No view found matching: ${viewClassName}`); return null; } if (matches.length === 1) { if (activityInfo) { matches[0].associatedActivity = activityInfo.fullClassName; } if (fragmentInfo) { matches[0].associatedFragment = fragmentInfo.fullClassName; } return matches[0]; } // Multiple matches - prefer based on fragment first, then activity const contextInfo = fragmentInfo || activityInfo; if (contextInfo) { for (const viewInfo of matches) { if (viewInfo.packageName === contextInfo.packageName) { if (activityInfo) { viewInfo.associatedActivity = activityInfo.fullClassName; } if (fragmentInfo) { viewInfo.associatedFragment = fragmentInfo.fullClassName; } logger.debug(`[SOURCE] Found view in same package as ${fragmentInfo ? "fragment" : "activity"}: ${viewInfo.fullClassName}`); return viewInfo; } } // If no exact package match, find the one with most similar package let bestMatch = matches[0]; let bestScore = 0; const contextParts = contextInfo.packageName.split("."); for (const viewInfo of matches) { const viewParts = viewInfo.packageName.split("."); let score = 0; const minLength = Math.min(contextParts.length, viewParts.length); for (let i = 0; i < minLength; i++) { if (contextParts[i] === viewParts[i]) { score++; } else { break; } } if (score > bestScore) { bestScore = score; bestMatch = viewInfo; } } if (activityInfo) { bestMatch.associatedActivity = activityInfo.fullClassName; } if (fragmentInfo) { bestMatch.associatedFragment = fragmentInfo.fullClassName; } logger.debug(`[SOURCE] Found best matching view: ${bestMatch.fullClassName} (score: ${bestScore})`); return bestMatch; } // No context, return first match return matches[0]; } /** * Find composable info by function name, with preference for composables in same package as activity or fragment */ public async findComposableInfo( appId: string, composableName: string, activityInfo?: ActivityInfo, fragmentInfo?: FragmentInfo ): Promise<ComposableInfo | null> { const sourceIndex = await this.getSourceIndex(appId); if (!sourceIndex) { return null; } // Look for exact match by full class name first for (const [fullClassName, composableInfo] of sourceIndex.composables) { if (fullClassName.endsWith(`.${composableName}`)) { if (activityInfo) { composableInfo.associatedActivity = activityInfo.fullClassName; } if (fragmentInfo) { composableInfo.associatedFragment = fragmentInfo.fullClassName; } return composableInfo; } } // Look for matches by function name only const matches: ComposableInfo[] = []; for (const [, composableInfo] of sourceIndex.composables) { if (composableInfo.className === composableName) { matches.push(composableInfo); } } if (matches.length === 0) { logger.warn(`No composable found matching: ${composableName}`); return null; } if (matches.length === 1) { if (activityInfo) { matches[0].associatedActivity = activityInfo.fullClassName; } if (fragmentInfo) { matches[0].associatedFragment = fragmentInfo.fullClassName; } return matches[0]; } // Multiple matches - prefer based on fragment first, then activity const contextInfo = fragmentInfo || activityInfo; if (contextInfo) { for (const composableInfo of matches) { if (composableInfo.packageName === contextInfo.packageName) { if (activityInfo) { composableInfo.associatedActivity = activityInfo.fullClassName; } if (fragmentInfo) { composableInfo.associatedFragment = fragmentInfo.fullClassName; } logger.debug(`[SOURCE] Found composable in same package as ${fragmentInfo ? "fragment" : "activity"}: ${composableInfo.fullClassName}`); return composableInfo; } } // If no exact package match, find the one with most similar package let bestMatch = matches[0]; let bestScore = 0; const contextParts = contextInfo.packageName.split("."); for (const composableInfo of matches) { const composableParts = composableInfo.packageName.split("."); let score = 0; const minLength = Math.min(contextParts.length, composableParts.length); for (let i = 0; i < minLength; i++) { if (contextParts[i] === composableParts[i]) { score++; } else { break; } } if (score > bestScore) { bestScore = score; bestMatch = composableInfo; } } if (activityInfo) { bestMatch.associatedActivity = activityInfo.fullClassName; } if (fragmentInfo) { bestMatch.associatedFragment = fragmentInfo.fullClassName; } logger.debug(`[SOURCE] Found best matching composable: ${bestMatch.fullClassName} (score: ${bestScore})`); return bestMatch; } // No context, return first match return matches[0]; } // =========================================== // Cache Management // =========================================== /** * Clear all caches */ public clearCache(): void { this.projectScanResultCache.clear(); this.sourceIndex.clear(); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/zillow/auto-mobile'

If you have feedback or need assistance with the MCP directory API, please join our Discord server