UNPKG

aiwg

Version:

Cognitive architecture for AI-augmented software development with structured memory, ensemble validation, and closed-loop correction. FAIR-aligned artifacts, 84% cost reduction via human-in-the-loop, standards adopted by 100+ organizations.

431 lines (353 loc) 12.7 kB
/** * UseCaseParser - Parse use case documents to extract testable scenarios * * Parses use case markdown documents following the standard template format * and extracts structured test scenarios for test generation. * * @module src/testing/generators/use-case-parser */ import * as fs from 'fs/promises'; import * as path from 'path'; // =========================== // Interfaces // =========================== export interface UseCaseStep { number: number; actor: 'user' | 'system' | 'external'; action: string; expectedResult?: string; } export interface UseCaseScenario { id: string; name: string; type: 'main' | 'extension' | 'exception'; preconditions: string[]; steps: UseCaseStep[]; postconditions: string[]; extensionOf?: string; // For extension scenarios triggeredAt?: number; // Step number where extension triggers } export interface UseCaseDocument { id: string; title: string; actor: string; description: string; preconditions: string[]; postconditions: string[]; mainScenario: UseCaseScenario; extensions: UseCaseScenario[]; exceptions: UseCaseScenario[]; nfrs: string[]; // Referenced NFR IDs relatedUseCases: string[]; priority: 'critical' | 'high' | 'medium' | 'low'; } export interface ParseResult { success: boolean; document?: UseCaseDocument; errors: string[]; warnings: string[]; } // =========================== // UseCaseParser Class // =========================== export class UseCaseParser { private readonly MAIN_SCENARIO_PATTERN = /^#+\s*(Main\s+)?Success\s+Scenario/im; private readonly EXTENSION_PATTERN = /^#+\s*Extensions?/im; private readonly EXCEPTION_PATTERN = /^#+\s*Exception(s|\s+Scenario)/im; private readonly STEP_PATTERN = /^\s*(\d+)\.\s+(?:\*\*(\w+)\*\*:?\s+)?(.+)/; private readonly PRECONDITION_PATTERN = /^#+\s*Pre-?conditions?/im; private readonly POSTCONDITION_PATTERN = /^#+\s*Post-?conditions?/im; private readonly NFR_REF_PATTERN = /NFR-[A-Z]+-\d+/g; private readonly UC_REF_PATTERN = /UC-\d+/g; /** * Parse a use case document from file * * @param filePath - Path to use case markdown file * @returns Parse result with structured document */ async parseFile(filePath: string): Promise<ParseResult> { try { const content = await fs.readFile(filePath, 'utf-8'); return this.parseContent(content, path.basename(filePath)); } catch (error: any) { return { success: false, errors: [`Failed to read file: ${error.message}`], warnings: [] }; } } /** * Parse use case content from string * * @param content - Markdown content * @param sourceName - Source identifier for error messages * @returns Parse result with structured document */ parseContent(content: string, sourceName: string = 'input'): ParseResult { const errors: string[] = []; const warnings: string[] = []; // Extract use case ID from content const idMatch = content.match(/UC-(\d+)/); const id = idMatch ? `UC-${idMatch[1]}` : this.extractIdFromFilename(sourceName); if (!id) { errors.push('Could not determine use case ID'); } // Extract title from first heading const titleMatch = content.match(/^#\s+(.+)/m); const title = titleMatch ? titleMatch[1].trim() : 'Unknown Use Case'; // Extract actor const actorMatch = content.match(/\*\*(?:Primary\s+)?Actor\*\*:\s*(.+)/i) || content.match(/Actor:\s*(.+)/i); const actor = actorMatch ? actorMatch[1].trim() : 'User'; // Extract description const descMatch = content.match(/\*\*Description\*\*:\s*(.+)/i) || content.match(/^>\s*(.+)/m); const description = descMatch ? descMatch[1].trim() : ''; // Extract priority const priorityMatch = content.match(/\*\*Priority\*\*:\s*(\w+)/i); const priority = this.normalizePriority(priorityMatch?.[1] || 'medium'); // Parse preconditions const preconditions = this.parseBulletList(content, this.PRECONDITION_PATTERN); // Parse postconditions const postconditions = this.parseBulletList(content, this.POSTCONDITION_PATTERN); // Parse main scenario const mainScenario = this.parseMainScenario(content, id || 'UC-000'); if (!mainScenario) { warnings.push('No main success scenario found'); } // Parse extensions const extensions = this.parseExtensions(content, id || 'UC-000'); // Parse exceptions const exceptions = this.parseExceptions(content, id || 'UC-000'); // Extract NFR references const nfrs = this.extractReferences(content, this.NFR_REF_PATTERN); // Extract related use cases const relatedUseCases = this.extractReferences(content, this.UC_REF_PATTERN) .filter(ref => ref !== id); if (errors.length > 0) { return { success: false, errors, warnings }; } return { success: true, document: { id: id!, title, actor, description, preconditions, postconditions, mainScenario: mainScenario || this.createEmptyScenario(id!, 'main'), extensions, exceptions, nfrs, relatedUseCases, priority }, errors: [], warnings }; } /** * Parse multiple use case files from a directory * * @param dirPath - Directory containing use case files * @returns Array of parse results */ async parseDirectory(dirPath: string): Promise<Map<string, ParseResult>> { const results = new Map<string, ParseResult>(); try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { if (entry.isFile() && entry.name.match(/UC-\d+.*\.md$/i)) { const filePath = path.join(dirPath, entry.name); const result = await this.parseFile(filePath); results.set(entry.name, result); } } } catch (error: any) { // Return empty map if directory doesn't exist } return results; } /** * Extract testable scenarios from a use case document * * @param document - Parsed use case document * @returns Array of all testable scenarios */ extractTestableScenarios(document: UseCaseDocument): UseCaseScenario[] { const scenarios: UseCaseScenario[] = []; // Add main scenario scenarios.push(document.mainScenario); // Add extensions scenarios.push(...document.extensions); // Add exceptions scenarios.push(...document.exceptions); return scenarios; } // =========================== // Private Parsing Methods // =========================== private parseMainScenario(content: string, useCaseId: string): UseCaseScenario | null { const match = content.match(this.MAIN_SCENARIO_PATTERN); if (!match) return null; const sectionStart = match.index! + match[0].length; const sectionContent = this.extractSectionContent(content, sectionStart); const steps = this.parseSteps(sectionContent); return { id: `${useCaseId}-MSS`, name: 'Main Success Scenario', type: 'main', preconditions: [], steps, postconditions: [] }; } private parseExtensions(content: string, useCaseId: string): UseCaseScenario[] { const extensions: UseCaseScenario[] = []; const match = content.match(this.EXTENSION_PATTERN); if (!match) return extensions; const sectionStart = match.index! + match[0].length; const sectionContent = this.extractSectionContent(content, sectionStart); // Parse extension scenarios (e.g., "2a. User cancels...") const extensionPattern = /(\d+)([a-z])\.\s+(.+?)(?=\n\d+[a-z]\.|\n#+|\n\n\n|$)/gs; let extMatch; while ((extMatch = extensionPattern.exec(sectionContent)) !== null) { const stepNum = parseInt(extMatch[1]); const extId = extMatch[2]; const extContent = extMatch[3].trim(); // Parse extension steps const extSteps = this.parseExtensionSteps(extContent); extensions.push({ id: `${useCaseId}-EXT-${stepNum}${extId.toUpperCase()}`, name: `Extension at step ${stepNum}`, type: 'extension', preconditions: [], steps: extSteps, postconditions: [], extensionOf: `${useCaseId}-MSS`, triggeredAt: stepNum }); } return extensions; } private parseExceptions(content: string, useCaseId: string): UseCaseScenario[] { const exceptions: UseCaseScenario[] = []; const match = content.match(this.EXCEPTION_PATTERN); if (!match) return exceptions; const sectionStart = match.index! + match[0].length; const sectionContent = this.extractSectionContent(content, sectionStart); // Parse exception scenarios const exceptionPattern = /\*\*([^*]+)\*\*:\s*(.+?)(?=\n\*\*|\n#+|\n\n\n|$)/gs; let exMatch; let counter = 1; while ((exMatch = exceptionPattern.exec(sectionContent)) !== null) { const name = exMatch[1].trim(); const description = exMatch[2].trim(); exceptions.push({ id: `${useCaseId}-EXC-${String(counter).padStart(2, '0')}`, name, type: 'exception', preconditions: [], steps: [{ number: 1, actor: 'system', action: description }], postconditions: [] }); counter++; } return exceptions; } private parseSteps(content: string): UseCaseStep[] { const steps: UseCaseStep[] = []; const lines = content.split('\n'); for (const line of lines) { const match = line.match(this.STEP_PATTERN); if (match) { const number = parseInt(match[1]); const actorHint = match[2]?.toLowerCase() || ''; const action = match[3].trim(); let actor: UseCaseStep['actor'] = 'system'; if (actorHint.includes('user') || action.toLowerCase().startsWith('user')) { actor = 'user'; } else if (actorHint.includes('external') || action.toLowerCase().includes('external')) { actor = 'external'; } steps.push({ number, actor, action }); } } return steps; } private parseExtensionSteps(content: string): UseCaseStep[] { const steps: UseCaseStep[] = []; const lines = content.split('\n'); let stepNum = 1; for (const line of lines) { const trimmed = line.trim(); if (trimmed && !trimmed.startsWith('#')) { // Determine actor from content let actor: UseCaseStep['actor'] = 'system'; if (trimmed.toLowerCase().includes('user')) { actor = 'user'; } steps.push({ number: stepNum++, actor, action: trimmed }); } } return steps; } private parseBulletList(content: string, sectionPattern: RegExp): string[] { const items: string[] = []; const match = content.match(sectionPattern); if (!match) return items; const sectionStart = match.index! + match[0].length; const sectionContent = this.extractSectionContent(content, sectionStart); // Parse bullet points const bulletPattern = /^\s*[-*]\s+(.+)/gm; let bulletMatch; while ((bulletMatch = bulletPattern.exec(sectionContent)) !== null) { items.push(bulletMatch[1].trim()); } return items; } private extractSectionContent(content: string, startIndex: number): string { // Find next heading or end of content const remaining = content.substring(startIndex); const nextHeading = remaining.search(/\n#+\s/); if (nextHeading === -1) { return remaining; } return remaining.substring(0, nextHeading); } private extractReferences(content: string, pattern: RegExp): string[] { const matches = content.match(pattern) || []; return [...new Set(matches)]; // Deduplicate } private extractIdFromFilename(filename: string): string | null { const match = filename.match(/UC-(\d+)/i); return match ? `UC-${match[1]}` : null; } private normalizePriority(priority: string): UseCaseDocument['priority'] { const lower = priority.toLowerCase(); if (lower.includes('critical') || lower === 'p0') return 'critical'; if (lower.includes('high') || lower === 'p1') return 'high'; if (lower.includes('low') || lower === 'p3') return 'low'; return 'medium'; } private createEmptyScenario(useCaseId: string, type: 'main' | 'extension' | 'exception'): UseCaseScenario { return { id: `${useCaseId}-${type.toUpperCase()}`, name: `Empty ${type} scenario`, type, preconditions: [], steps: [], postconditions: [] }; } }