UNPKG

vibe-coder-mcp

Version:

Production-ready MCP server with complete agent integration, multi-transport support, and comprehensive development automation tools for AI-assisted workflows.

615 lines (614 loc) 28.9 kB
import fs from 'fs/promises'; import path from 'path'; import logger from '../../../logger.js'; import { UnifiedSecurityEngine, createDefaultSecurityConfig } from '../core/unified-security-engine.js'; export class PRDIntegrationService { static instance; config; prdCache = new Map(); performanceMetrics = new Map(); securityEngine = null; constructor() { this.config = { maxAge: 24 * 60 * 60 * 1000, enableCaching: true, maxCacheSize: 50, enablePerformanceMonitoring: true }; logger.debug('PRD integration service initialized'); } async getSecurityEngine() { if (!this.securityEngine) { const config = createDefaultSecurityConfig(); this.securityEngine = UnifiedSecurityEngine.getInstance(config); await this.securityEngine.initialize(); } return this.securityEngine; } static getInstance() { if (!PRDIntegrationService.instance) { PRDIntegrationService.instance = new PRDIntegrationService(); } return PRDIntegrationService.instance; } async parsePRD(prdFilePath) { const startTime = Date.now(); try { logger.info({ prdFilePath }, 'Starting PRD parsing'); await this.validatePRDPath(prdFilePath); const prdContent = await fs.readFile(prdFilePath, 'utf-8'); const prdData = await this.parsePRDContent(prdContent, prdFilePath); const parsingTime = Date.now() - startTime; if (this.config.enableCaching) { await this.updatePRDCache(prdFilePath); } logger.info({ prdFilePath, parsingTime, featureCount: prdData.features.length }, 'PRD parsing completed successfully'); return { success: true, prdData, parsingTime }; } catch (error) { const parsingTime = Date.now() - startTime; logger.error({ err: error, prdFilePath }, 'PRD parsing failed with exception'); return { success: false, error: error instanceof Error ? error.message : String(error), parsingTime }; } } async detectExistingPRD(projectPath) { try { if (this.config.enableCaching && projectPath && this.prdCache.has(projectPath)) { const cached = this.prdCache.get(projectPath); try { await fs.access(cached.filePath); return cached; } catch { this.prdCache.delete(projectPath); } } const prdFiles = await this.findPRDFiles(projectPath); if (prdFiles.length === 0) { return null; } const mostRecent = prdFiles.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]; if (this.config.enableCaching && projectPath) { this.prdCache.set(projectPath, mostRecent); } return mostRecent; } catch (error) { logger.warn({ err: error, projectPath }, 'Failed to detect existing PRD'); return null; } } async validatePRDPath(prdFilePath) { try { const securityEngine = await this.getSecurityEngine(); const validationResponse = await securityEngine.validatePath(prdFilePath, 'read'); if (!validationResponse.success) { throw new Error(`Security validation failed: ${validationResponse.error?.message || 'Unknown error'}`); } const validationResult = validationResponse.data; if (!validationResult.isValid) { throw new Error(`Security validation failed: ${validationResult.error || 'Path validation failed'}`); } if (validationResult.warnings && validationResult.warnings.length > 0) { logger.warn({ prdFilePath, warnings: validationResult.warnings }, 'PRD path validation warnings'); } if (!prdFilePath.endsWith('.md')) { throw new Error('PRD file must be a Markdown file (.md)'); } } catch (error) { logger.error({ err: error, prdFilePath }, 'PRD path validation failed'); throw new Error(`Invalid PRD file path: ${error instanceof Error ? error.message : String(error)}`); } } async updatePRDCache(prdFilePath) { try { const stats = await fs.stat(prdFilePath); const fileName = path.basename(prdFilePath); const { projectName, createdAt } = this.extractPRDMetadataFromFilename(fileName); const prdInfo = { filePath: prdFilePath, fileName, createdAt, projectName, fileSize: stats.size, isAccessible: true, lastModified: stats.mtime }; this.prdCache.set(projectName, prdInfo); if (this.prdCache.size > this.config.maxCacheSize) { const oldestKey = this.prdCache.keys().next().value; if (oldestKey) { this.prdCache.delete(oldestKey); } } } catch (error) { logger.warn({ err: error, prdFilePath }, 'Failed to update PRD cache'); } } splitCompoundWord(word) { const parts = word.split(/(?=[A-Z])|[-_]|(?<=[a-z])(?=[0-9])/); const normalizedParts = parts.map(part => { if (part.endsWith('z') && part.length > 2) { const withoutZ = part.slice(0, -1); if (/[aeiou]$/.test(withoutZ)) { return withoutZ + 's'; } } return part; }); const spacedVersion = word .replace(/([a-z])([A-Z])/g, '$1 $2') .replace(/([a-z])z([a-z])/g, '$1s $2') .toLowerCase(); const allParts = [...normalizedParts, ...spacedVersion.split(' ')]; return Array.from(new Set(allParts.filter(p => p.length > 0))); } extractPRDMetadataFromFilename(fileName) { const match = fileName.match(/^(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)-(.+)-prd\.md$/); if (match) { const [, timestamp, projectSlug] = match; const createdAt = new Date(timestamp.replace(/-/g, ':').replace(/T(\d{2}):(\d{2}):(\d{2}):(\d{3})Z/, 'T$1:$2:$3.$4Z')); const projectName = projectSlug.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); return { projectName, createdAt }; } return { projectName: fileName.replace(/-prd\.md$/, '').replace(/-/g, ' '), createdAt: new Date() }; } async findPRDFiles(projectPath) { try { const outputBaseDir = process.env.VIBE_CODER_OUTPUT_DIR || path.join(process.cwd(), 'VibeCoderOutput'); const prdOutputDir = path.join(outputBaseDir, 'prd-generator'); try { await fs.access(prdOutputDir); } catch { return []; } const files = await fs.readdir(prdOutputDir, { withFileTypes: true }); const prdFiles = []; for (const file of files) { if (file.isFile() && file.name.endsWith('-prd.md')) { const filePath = path.join(prdOutputDir, file.name); try { const stats = await fs.stat(filePath); const { projectName, createdAt } = this.extractPRDMetadataFromFilename(file.name); if (projectPath) { const projectPathLower = projectPath.toLowerCase(); const projectNameLower = projectName.toLowerCase(); if (projectPathLower.startsWith('pid-')) { const projectIdParts = projectPathLower.replace('pid-', '').split('-'); const hasMatchingParts = projectIdParts.some(part => { if (part.length <= 2) { logger.debug({ part, reason: 'too_short' }, 'Skipping PRD match for short part'); return false; } if (projectNameLower.includes(part)) { logger.debug({ part, projectName: projectNameLower, matchType: 'exact' }, 'PRD match found via exact match'); return true; } const splitParts = this.splitCompoundWord(part); logger.debug({ originalPart: part, splitParts, projectName: projectNameLower }, 'Attempting fuzzy PRD match with split parts'); for (const splitPart of splitParts) { if (splitPart.length > 1 && projectNameLower.includes(splitPart.toLowerCase())) { logger.debug({ originalPart: part, matchedPart: splitPart, projectName: projectNameLower, matchType: 'split_part' }, 'PRD match found via split part'); return true; } } const compoundParts = splitParts.filter(p => p.length > 2); if (compoundParts.length > 1) { const allPartsMatch = compoundParts.every(word => projectNameLower.includes(word.toLowerCase())); if (allPartsMatch) { logger.debug({ originalPart: part, matchedParts: compoundParts, projectName: projectNameLower, matchType: 'all_compound_parts' }, 'PRD match found via all compound parts'); return true; } } logger.debug({ part, splitParts, projectName: projectNameLower, reason: 'no_match' }, 'No PRD match found for part'); return false; }); const isPlatformProject = projectNameLower.includes('platform') || projectNameLower.includes('web') || projectNameLower.includes('based'); const hasEducationalTerms = projectIdParts.some(part => ['edu', 'play', 'game', 'learn', 'platform'].includes(part)); logger.debug({ projectId: projectPathLower, projectName: projectNameLower, hasMatchingParts, isPlatformProject, hasEducationalTerms, strategies: { fuzzyMatch: hasMatchingParts, platformMatch: isPlatformProject, educationalMatch: hasEducationalTerms } }, 'PRD matching strategies evaluated'); if (!hasMatchingParts && !isPlatformProject && !hasEducationalTerms) { logger.debug({ projectId: projectPathLower, fileName: file.name, reason: 'no_strategy_matched' }, 'Skipping PRD file - no matching strategy succeeded'); continue; } } else { const expectedProjectName = path.basename(projectPath).toLowerCase(); if (!projectNameLower.includes(expectedProjectName)) { continue; } } } prdFiles.push({ filePath, fileName: file.name, createdAt, projectName, fileSize: stats.size, isAccessible: true, lastModified: stats.mtime }); } catch (error) { logger.warn({ err: error, fileName: file.name }, 'Failed to process PRD file'); const { projectName, createdAt } = this.extractPRDMetadataFromFilename(file.name); prdFiles.push({ filePath: path.join(prdOutputDir, file.name), fileName: file.name, createdAt, projectName, fileSize: 0, isAccessible: false, lastModified: new Date() }); } } } return prdFiles; } catch (error) { logger.error({ err: error, projectPath }, 'Failed to find PRD files'); return []; } } async parsePRDContent(content, filePath) { const startTime = Date.now(); try { const securityEngine = await this.getSecurityEngine(); const validationResponse = await securityEngine.validatePath(filePath, 'read'); if (!validationResponse.success) { throw new Error(`Security validation failed: ${validationResponse.error?.message || 'Unknown error'}`); } const validationResult = validationResponse.data; if (!validationResult.isValid) { throw new Error(`Security validation failed: ${validationResult.error || 'Path validation failed'}`); } const lines = content.split('\n'); const fileName = path.basename(filePath); const { projectName, createdAt } = this.extractPRDMetadataFromFilename(fileName); const stats = await fs.stat(validationResult.normalizedPath || filePath); const parsedPRD = { metadata: { filePath, projectName, createdAt, fileSize: stats.size }, overview: { description: '', businessGoals: [], productGoals: [], successMetrics: [] }, targetAudience: { primaryUsers: [], demographics: [], userNeeds: [] }, features: [], technical: { techStack: [], architecturalPatterns: [], performanceRequirements: [], securityRequirements: [], scalabilityRequirements: [] }, constraints: { timeline: [], budget: [], resources: [], technical: [] } }; let currentSection = ''; let currentSubsection = ''; let featureId = 1; let currentFeature = null; let inUserStory = false; let inAcceptanceCriteria = false; for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); if (line.startsWith('# ')) { currentSection = line.substring(2).toLowerCase(); currentSubsection = ''; currentFeature = null; continue; } if (line.startsWith('## ')) { currentSubsection = line.substring(3).toLowerCase(); currentFeature = null; continue; } if (line.startsWith('### ')) { const subsectionTitle = line.substring(4); currentSubsection = subsectionTitle.toLowerCase(); if (currentSection.includes('feature') || currentSection.includes('functionality')) { const featureMatch = subsectionTitle.match(/^(\d+\.?\d*\.?)\s*(.+)$/); if (featureMatch) { const [, , featureTitle] = featureMatch; currentFeature = { id: `F${featureId.toString().padStart(3, '0')}`, title: featureTitle.trim(), description: '', userStories: [], acceptanceCriteria: [], priority: 'medium' }; parsedPRD.features.push(currentFeature); featureId++; inUserStory = false; inAcceptanceCriteria = false; } } continue; } if (currentFeature && line.startsWith('**')) { if (line.toLowerCase().includes('user story:')) { inUserStory = true; inAcceptanceCriteria = false; continue; } else if (line.toLowerCase().includes('acceptance criteria:')) { inUserStory = false; inAcceptanceCriteria = true; continue; } else if (line.toLowerCase().includes('description:')) { inUserStory = false; inAcceptanceCriteria = false; continue; } } if (currentFeature) { if (inUserStory && line.length > 0) { currentFeature.userStories.push(line); } else if (inAcceptanceCriteria && line.startsWith('- ')) { currentFeature.acceptanceCriteria.push(line.substring(2)); } else if (!inUserStory && !inAcceptanceCriteria && line.length > 0 && !line.startsWith('**')) { if (currentFeature.description) { currentFeature.description += ' '; } currentFeature.description += line; } } if (!currentFeature) { this.parsePRDSection(line, currentSection, currentSubsection, parsedPRD, featureId); } if (currentSection.includes('feature') && line.startsWith('- **') && line.includes(':**')) { const match = line.match(/- \*\*(.+?):\*\*\s*(.+)/); if (match) { const [, title, description] = match; currentFeature = { id: `F${featureId.toString().padStart(3, '0')}`, title: title.trim(), description: description.trim(), userStories: [], acceptanceCriteria: [], priority: 'medium' }; parsedPRD.features.push(currentFeature); featureId++; } } } if (this.config.enablePerformanceMonitoring) { const parsingTime = Date.now() - startTime; this.performanceMetrics.set(filePath, { parsingTime, fileSize: stats.size, featureCount: parsedPRD.features.length, sectionCount: 5 }); } logger.info({ filePath, featureCount: parsedPRD.features.length, features: parsedPRD.features.map(f => ({ id: f.id, title: f.title })) }, 'PRD content parsed successfully'); return parsedPRD; } catch (error) { logger.error({ err: error, filePath }, 'Failed to parse PRD content'); throw error; } } parsePRDSection(line, section, subsection, parsedPRD, featureId) { if (!line || line.startsWith('#')) return; if (section.includes('introduction') || section.includes('overview') || section.includes('comprehensive app prd')) { if (subsection.includes('description') && line.length > 10 && !line.startsWith('- ')) { parsedPRD.overview.description += line + ' '; } else if (line.startsWith('- ')) { if (subsection.includes('business') && subsection.includes('goal')) { parsedPRD.overview.businessGoals.push(line.substring(2)); } else if (subsection.includes('product') && subsection.includes('goal')) { parsedPRD.overview.productGoals.push(line.substring(2)); } else if (subsection.includes('success') && subsection.includes('metric')) { parsedPRD.overview.successMetrics.push(line.substring(2)); } } if (!subsection && line.length > 10 && !line.startsWith('- ') && !line.startsWith('#')) { parsedPRD.overview.description += line + ' '; } } if (section.includes('target') || section.includes('audience')) { if (line.startsWith('- ')) { if (subsection.includes('user') || subsection.includes('primary')) { parsedPRD.targetAudience.primaryUsers.push(line.substring(2)); } else if (subsection.includes('demographic')) { parsedPRD.targetAudience.demographics.push(line.substring(2)); } else if (subsection.includes('need')) { parsedPRD.targetAudience.userNeeds.push(line.substring(2)); } } } if (section.includes('feature') || section.includes('functionality')) { if (line.startsWith('- **') && line.includes(':**')) { const match = line.match(/- \*\*(.+?):\*\*\s*(.+)/); if (match) { const [, title, description] = match; parsedPRD.features.push({ id: `F${featureId.toString().padStart(3, '0')}`, title: title.trim(), description: description.trim(), userStories: [], acceptanceCriteria: [], priority: 'medium' }); } } else if (line.startsWith(' - ') && parsedPRD.features.length > 0) { const lastFeature = parsedPRD.features[parsedPRD.features.length - 1]; if (subsection.includes('story') || subsection.includes('user')) { lastFeature.userStories.push(line.substring(4)); } else if (subsection.includes('criteria') || subsection.includes('acceptance')) { lastFeature.acceptanceCriteria.push(line.substring(4)); } } } if (section.includes('technical') || section.includes('technology')) { if (line.startsWith('- ')) { if (subsection.includes('stack') || subsection.includes('technology')) { parsedPRD.technical.techStack.push(line.substring(2)); } else if (subsection.includes('pattern') || subsection.includes('architecture')) { parsedPRD.technical.architecturalPatterns.push(line.substring(2)); } else if (subsection.includes('performance')) { parsedPRD.technical.performanceRequirements.push(line.substring(2)); } else if (subsection.includes('security')) { parsedPRD.technical.securityRequirements.push(line.substring(2)); } else if (subsection.includes('scalability')) { parsedPRD.technical.scalabilityRequirements.push(line.substring(2)); } } } if (section.includes('constraint') || section.includes('limitation')) { if (line.startsWith('- ')) { if (subsection.includes('timeline') || subsection.includes('schedule')) { parsedPRD.constraints.timeline.push(line.substring(2)); } else if (subsection.includes('budget') || subsection.includes('cost')) { parsedPRD.constraints.budget.push(line.substring(2)); } else if (subsection.includes('resource') || subsection.includes('team')) { parsedPRD.constraints.resources.push(line.substring(2)); } else if (subsection.includes('technical')) { parsedPRD.constraints.technical.push(line.substring(2)); } } } } async getPRDMetadata(prdFilePath) { try { await this.validatePRDPath(prdFilePath); const stats = await fs.stat(prdFilePath); const fileName = path.basename(prdFilePath); const { createdAt } = this.extractPRDMetadataFromFilename(fileName); const performanceMetrics = this.performanceMetrics.get(prdFilePath) || { parsingTime: 0, fileSize: stats.size, featureCount: 0, sectionCount: 0 }; return { filePath: prdFilePath, projectPath: '', createdAt, fileSize: stats.size, version: '1.0', performanceMetrics }; } catch (error) { logger.error({ err: error, prdFilePath }, 'Failed to get PRD metadata'); throw error; } } clearCache() { this.prdCache.clear(); this.performanceMetrics.clear(); logger.info('PRD integration cache cleared'); } updateConfig(newConfig) { this.config = { ...this.config, ...newConfig }; logger.debug({ config: this.config }, 'PRD integration configuration updated'); } getConfig() { return { ...this.config }; } getPerformanceMetrics() { return new Map(this.performanceMetrics); } }