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
JavaScript
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);
}
}