UNPKG

mcp-adr-analysis-server

Version:

MCP server for analyzing Architectural Decision Records and project architecture

326 lines 13.8 kB
/** * ADR Discovery Utilities * * Utilities for discovering and analyzing existing ADRs in the project */ import { McpAdrError } from '../types/index.js'; /** * Discover ADRs in a directory using file system operations * * @param adrDirectory - Relative path to ADR directory * @param projectPath - Root path of the project * @param options - Discovery options * @returns Promise resolving to ADR discovery results * @throws McpAdrError if directory access fails or parsing errors occur */ export async function discoverAdrsInDirectory(adrDirectory, projectPath, options = {}) { const { includeContent = false, includeTimeline = true, // Default to true for smart extraction timelineOptions = {}, generateActions = false, thresholdProfile, autoDetectContext = true, } = options; try { const fs = await import('fs/promises'); const path = await import('path'); // Try multiple strategies to resolve the correct ADR directory path // This handles cases where: // 1. projectPath is already in a subdirectory (e.g., /project/docs) // 2. adrDirectory is relative (e.g., "docs/adrs") // 3. adrDirectory is already absolute let fullAdrPath; let dirExists = false; // Strategy 1: Try resolving as-is (standard behavior) fullAdrPath = path.resolve(projectPath, adrDirectory); try { const stat = await fs.stat(fullAdrPath); dirExists = stat.isDirectory(); } catch { dirExists = false; } // Strategy 2: If that fails and adrDirectory contains the same path segment as projectPath, // the projectPath might be pointing to a subdirectory. Try stripping the common suffix. if (!dirExists && adrDirectory.includes(path.sep)) { const projectPathParts = projectPath.split(path.sep); const adrDirParts = adrDirectory.split(path.sep); // Check if projectPath ends with the first part of adrDirectory // e.g., projectPath ends with "docs" and adrDirectory starts with "docs/adrs" const lastProjectPart = projectPathParts[projectPathParts.length - 1]; if (lastProjectPart && lastProjectPart === adrDirParts[0]) { // Remove the redundant first part from adrDirectory const adjustedAdrDir = adrDirParts.slice(1).join(path.sep); fullAdrPath = path.resolve(projectPath, adjustedAdrDir); try { const stat = await fs.stat(fullAdrPath); dirExists = stat.isDirectory(); } catch { dirExists = false; } } } // Strategy 3: If still not found, try checking one level up from projectPath if (!dirExists) { const parentPath = path.dirname(projectPath); fullAdrPath = path.resolve(parentPath, adrDirectory); try { const stat = await fs.stat(fullAdrPath); dirExists = stat.isDirectory(); } catch { dirExists = false; } } if (!dirExists) { return { directory: adrDirectory, totalAdrs: 0, adrs: [], summary: { byStatus: {}, byCategory: {} }, recommendations: [ `ADR directory '${adrDirectory}' does not exist (tried multiple path resolutions)`, `Attempted paths: ${path.resolve(projectPath, adrDirectory)}, ${path.resolve(path.dirname(projectPath), adrDirectory)}`, 'Consider creating the directory and adding your first ADR', 'Use the generate_adr_from_decision tool to create new ADRs', ], }; } // Read directory contents const entries = await fs.readdir(fullAdrPath, { withFileTypes: true }); const markdownFiles = entries .filter(entry => entry.isFile() && entry.name.endsWith('.md')) .map(entry => entry.name); // Process each markdown file to check if it's an ADR const discoveredAdrs = []; for (const filename of markdownFiles) { const filePath = path.join(fullAdrPath, filename); try { // Read the file content to check if it's an ADR const content = await fs.readFile(filePath, 'utf-8'); // Check if this looks like an ADR const isAdr = isLikelyAdr(content, filename); if (isAdr) { const adr = parseAdrMetadata(content, filename, path.join(adrDirectory, filename)); if (includeContent) { adr.content = content; } // Extract timeline if requested if (includeTimeline) { try { const { extractBasicTimeline } = await import('./adr-timeline-extractor.js'); adr.timeline = await extractBasicTimeline(filePath, content, timelineOptions); } catch (error) { console.warn(`[Timeline] Failed to extract timeline for ${filename}:`, error); // Continue without timeline data } } discoveredAdrs.push(adr); } } catch (error) { console.error(`[WARN] Failed to read file ${filename}:`, error); } } // Generate summary const summary = generateAdrSummary(discoveredAdrs); // Generate recommendations const recommendations = generateRecommendations(discoveredAdrs, adrDirectory); // Generate action items if requested let actionQueue; if (generateActions && discoveredAdrs.some((adr) => adr.timeline)) { try { const { detectProjectContext, selectThresholdProfile } = await import('./adr-context-detector.js'); const { generateActionItems } = await import('./adr-action-analyzer.js'); // Detect project context or use manual profile let selectedProfile; if (autoDetectContext && !thresholdProfile) { const context = await detectProjectContext(projectPath, discoveredAdrs); selectedProfile = selectThresholdProfile(context); } else { const { THRESHOLD_PROFILES } = await import('./adr-context-detector.js'); selectedProfile = THRESHOLD_PROFILES[thresholdProfile || 'mature'] || THRESHOLD_PROFILES['mature']; } // Generate action items actionQueue = await generateActionItems(discoveredAdrs, selectedProfile, { useAdrTypeModifiers: true, projectPath, }); // Add action summary to summary object summary.totalActionsRequired = actionQueue.summary.totalActions; summary.criticalActions = actionQueue.summary.criticalCount; } catch (error) { console.warn('[Actions] Failed to generate action items:', error); // Continue without action items } } return { directory: adrDirectory, totalAdrs: discoveredAdrs.length, adrs: discoveredAdrs, summary, recommendations, ...(actionQueue ? { actionQueue } : {}), }; } catch (error) { throw new McpAdrError(`Failed to discover ADRs: ${error instanceof Error ? error.message : String(error)}`, 'DISCOVERY_ERROR'); } } /** * Check if a file is likely an ADR based on content and filename */ function isLikelyAdr(content, filename) { const contentLower = content.toLowerCase(); const filenameLower = filename.toLowerCase(); // Check filename patterns const filenamePatterns = [ /^adr[-_]?\d+/i, // ADR-001, ADR_001, adr-001 /^\d+[-_]/i, // 001-, 0001_ /architectural[-_]?decision/i, // architectural-decision /decision[-_]?record/i, // decision-record ]; const hasAdrFilename = filenamePatterns.some(pattern => pattern.test(filenameLower)); // Check content patterns const contentPatterns = [ /# .*decision/i, /## status/i, /## context/i, /## decision/i, /## consequences/i, /architectural decision record/i, /decision record/i, /adr[-_]?\d+/i, ]; const hasAdrContent = contentPatterns.some(pattern => pattern.test(contentLower)); // Must have either ADR filename pattern OR ADR content patterns return hasAdrFilename || hasAdrContent; } /** * Parse ADR metadata from content */ function parseAdrMetadata(content, filename, fullPath) { // Extract title (usually first # heading) let title = filename.replace(/\.md$/, ''); const titleMatch = content.match(/^#\s+(.+)$/m); if (titleMatch && titleMatch[1]) { title = titleMatch[1].trim(); } // Extract status let status = 'unknown'; const statusMatch = content.match(/(?:##?\s*status|status:)\s*(.+?)(?:\n|$)/i); if (statusMatch && statusMatch[1]) { status = statusMatch[1].trim().toLowerCase(); } // Extract date let date; const dateMatch = content.match(/(?:##?\s*date|date:)\s*(.+?)(?:\n|$)/i); if (dateMatch && dateMatch[1]) { date = dateMatch[1].trim(); } // Extract ADR number from filename or content let number; const numberMatch = filename.match(/(?:adr[-_]?)?(\d+)/i) || content.match(/adr[-_]?(\d+)/i); if (numberMatch && numberMatch[1]) { number = numberMatch[1]; } // Extract context let context; const contextMatch = content.match(/##?\s*context\s*\n([\s\S]*?)(?=\n##|\n#|$)/i); if (contextMatch && contextMatch[1]) { context = contextMatch[1].trim(); } // Extract decision let decision; const decisionMatch = content.match(/##?\s*decision\s*\n([\s\S]*?)(?=\n##|\n#|$)/i); if (decisionMatch && decisionMatch[1]) { decision = decisionMatch[1].trim(); } // Extract consequences let consequences; const consequencesMatch = content.match(/##?\s*consequences\s*\n([\s\S]*?)(?=\n##|\n#|$)/i); if (consequencesMatch && consequencesMatch[1]) { consequences = consequencesMatch[1].trim(); } // Extract category/tags (if any) const tags = []; const tagMatch = content.match(/(?:tags?|categories?):\s*(.+?)(?:\n|$)/i); if (tagMatch && tagMatch[1]) { tags.push(...tagMatch[1].split(',').map(tag => tag.trim())); } const metadata = { tags }; if (number) metadata.number = number; if (tags[0]) metadata.category = tags[0]; const result = { filename, title, status, date, path: fullPath, metadata, }; if (context) result.context = context; if (decision) result.decision = decision; if (consequences) result.consequences = consequences; return result; } /** * Generate summary statistics */ function generateAdrSummary(adrs) { const byStatus = {}; const byCategory = {}; for (const adr of adrs) { // Count by status byStatus[adr.status] = (byStatus[adr.status] || 0) + 1; // Count by category const category = adr.metadata?.category || 'uncategorized'; byCategory[category] = (byCategory[category] || 0) + 1; } return { byStatus, byCategory }; } /** * Generate recommendations based on discovered ADRs */ function generateRecommendations(adrs, adrDirectory) { const recommendations = []; if (adrs.length === 0) { recommendations.push(`No ADRs found in ${adrDirectory}`, 'Consider creating your first ADR using the generate_adr_from_decision tool', 'Use suggest_adrs tool to identify architectural decisions that need documentation'); } else { recommendations.push(`Found ${adrs.length} ADRs in ${adrDirectory}`); // Check for status distribution const statuses = [...new Set(adrs.map(adr => adr.status))]; if (statuses.includes('proposed') || statuses.includes('draft')) { recommendations.push('Consider reviewing and updating proposed/draft ADRs'); } // Check for numbering gaps const numbers = adrs .map(adr => adr.metadata?.number) .filter((n) => n !== undefined) .map(n => parseInt(n, 10)) .sort((a, b) => a - b); if (numbers.length > 1) { const gaps = []; for (let i = 1; i < numbers.length; i++) { const prev = numbers[i - 1]; const curr = numbers[i]; if (prev !== undefined && curr !== undefined && curr - prev > 1) { gaps.push(`${prev + 1}-${curr - 1}`); } } if (gaps.length > 0) { recommendations.push(`Consider filling ADR numbering gaps: ${gaps.join(', ')}`); } } // Suggest using discovered ADRs for analysis recommendations.push('Use suggest_adrs tool with existingAdrs parameter to find missing decisions', 'Use generate_adr_todo tool to create implementation tasks from these ADRs'); } return recommendations; } //# sourceMappingURL=adr-discovery.js.map