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.

932 lines (800 loc) 27.6 kB
/** * Traceability Checker - Maintain 100% bidirectional traceability * from requirements → code → tests → deployment * * Features: * - Bidirectional tracing (requirements ↔ code ↔ tests) * - Orphan detection (requirements without code, code without requirements) * - Coverage analysis (percentage of requirements traced) * - Gap identification (missing code, tests, documentation) * - Matrix generation (CSV, Excel, Markdown, HTML) * - Construction gate validation (100% P0 requirements traced) * * Performance targets: * - NFR-TRACE-001: ID extraction <1min for 1000 files * - NFR-TRACE-05: Matrix generation <30s for 1000 requirements */ import * as fs from 'fs/promises'; import * as path from 'path'; import { glob } from 'glob'; import { IDExtractor } from './id-extractor.js'; import { MatrixGenerator, TraceabilityMatrix, MatrixExportOptions } from './matrix-generator.js'; export interface Requirement { id: string; type: 'use-case' | 'nfr' | 'user-story' | 'feature' | 'acceptance-criteria'; title: string; priority?: 'P0' | 'P1' | 'P2'; filePath: string; } export interface CodeReference { filePath: string; requirementIds: string[]; lineNumbers: number[]; } export interface TestReference { filePath: string; requirementIds: string[]; testNames: string[]; } export interface ScanResult { requirements: Map<string, Requirement>; code: Map<string, CodeReference>; tests: Map<string, TestReference>; scanTime: number; } export interface TraceabilityLink { requirementId: string; type: 'use-case' | 'nfr' | 'user-story' | 'feature' | 'acceptance-criteria'; linkedItems: LinkedItem[]; coverage: CoverageMetrics; lastVerified: Date; } export interface LinkedItem { type: 'code' | 'test' | 'documentation' | 'deployment'; path: string; lineNumber?: number; verified: boolean; confidence: number; } export interface CoverageMetrics { hasCode: boolean; hasTests: boolean; hasDocumentation: boolean; hasDeployment: boolean; completeness: number; } export interface TraceabilityReport { totalRequirements: number; tracedRequirements: number; coveragePercentage: number; orphanedRequirements: string[]; orphanedCode: string[]; orphanedTests: string[]; gapsByRequirement: Map<string, string[]>; matrix: TraceabilityMatrix; } export interface VerificationResult { verified: boolean; issues: string[]; warnings: string[]; } export interface OrphanReport { orphanedRequirements: string[]; orphanedCode: string[]; orphanedTests: string[]; severity: Map<string, 'critical' | 'warning' | 'info'>; } export interface CoverageReport { percentage: number; byType: Map<string, number>; byPriority: Map<string, number>; gaps: GapAnalysis; } export interface GapAnalysis { requirementsWithoutCode: string[]; requirementsWithoutTests: string[]; requirementsWithoutDocumentation: string[]; codeWithoutRequirements: string[]; testsWithoutRequirements: string[]; } export interface ValidationResult { passed: boolean; coverage: number; threshold: number; issues: string[]; } export interface GateValidationResult { passed: boolean; p0Coverage: number; p1Coverage: number; issues: string[]; warnings: string[]; } /** * TraceabilityChecker - Main class for traceability analysis */ export class TraceabilityChecker { private projectPath: string; private idExtractor: IDExtractor; private matrixGenerator: MatrixGenerator; // Cached scan results private requirements: Map<string, Requirement> = new Map(); private codeReferences: Map<string, CodeReference> = new Map(); private testReferences: Map<string, TestReference> = new Map(); constructor(projectPath: string) { this.projectPath = path.resolve(projectPath); this.idExtractor = new IDExtractor(); this.matrixGenerator = new MatrixGenerator(); } /** * Scan requirements directory for requirement IDs */ async scanRequirements(requirementsPath?: string): Promise<Map<string, Requirement>> { const scanPath = requirementsPath || path.join(this.projectPath, '.aiwg/requirements'); const requirements = new Map<string, Requirement>(); try { // Find all markdown and YAML files const files = await glob('**/*.{md,yaml,yml}', { cwd: scanPath, absolute: true, nodir: true }); // Process files in parallel await Promise.all( files.map(async (filePath) => { const content = await fs.readFile(filePath, 'utf-8'); const ids = this.idExtractor.extractFromContent(content, filePath); // Extract requirement details for (const reqId of ids) { if (!requirements.has(reqId.id)) { const title = this.extractTitle(content, reqId.id); const priority = this.extractPriority(content); requirements.set(reqId.id, { id: reqId.id, type: reqId.type, title, priority, filePath }); } } }) ); this.requirements = requirements; return requirements; } catch (error) { // Directory doesn't exist or other error this.requirements = new Map(); return new Map(); } } /** * Scan code directory for requirement IDs */ async scanCode(codePath?: string): Promise<Map<string, CodeReference>> { const scanPath = codePath || path.join(this.projectPath, 'src'); const codeRefs = new Map<string, CodeReference>(); try { // Find all source files const files = await glob('**/*.{ts,js,mjs,tsx,jsx}', { cwd: scanPath, absolute: true, nodir: true, ignore: ['**/node_modules/**', '**/dist/**', '**/*.test.{ts,js}', '**/*.spec.{ts,js}'] }); // Process files in parallel await Promise.all( files.map(async (filePath) => { const content = await fs.readFile(filePath, 'utf-8'); const ids = this.idExtractor.extractFromContent(content, filePath); // Always add file, even if no IDs (needed for orphan detection) codeRefs.set(filePath, { filePath, requirementIds: ids.map(id => id.id), lineNumbers: ids.map(id => id.lineNumber || 0) }); }) ); this.codeReferences = codeRefs; return codeRefs; } catch (error) { this.codeReferences = new Map(); return new Map(); } } /** * Scan test directory for requirement IDs */ async scanTests(testPath?: string): Promise<Map<string, TestReference>> { const scanPath = testPath || path.join(this.projectPath, 'test'); const testRefs = new Map<string, TestReference>(); try { // Find all test files const files = await glob('**/*.{test,spec}.{ts,js,mjs}', { cwd: scanPath, absolute: true, nodir: true, ignore: ['**/node_modules/**', '**/dist/**'] }); // Process files in parallel await Promise.all( files.map(async (filePath) => { const content = await fs.readFile(filePath, 'utf-8'); const ids = this.idExtractor.extractFromContent(content, filePath); const testNames = this.extractTestNames(content); // Always add file, even if no IDs (needed for orphan detection) testRefs.set(filePath, { filePath, requirementIds: ids.map(id => id.id), testNames }); }) ); this.testReferences = testRefs; return testRefs; } catch (error) { this.testReferences = new Map(); return new Map(); } } /** * Scan all directories (requirements, code, tests) */ async scanAll(): Promise<ScanResult> { const startTime = performance.now(); // Scan in parallel const [requirements, code, tests] = await Promise.all([ this.scanRequirements(), this.scanCode(), this.scanTests() ]); const scanTime = performance.now() - startTime; return { requirements, code, tests, scanTime }; } /** * Build traceability links from scan results */ async buildTraceabilityLinks(): Promise<Map<string, TraceabilityLink>> { const links = new Map<string, TraceabilityLink>(); // Build links for each requirement for (const [reqId, req] of this.requirements) { const linkedItems: LinkedItem[] = []; // Find code references for (const [filePath, codeRef] of this.codeReferences) { if (codeRef.requirementIds.includes(reqId)) { linkedItems.push({ type: 'code', path: filePath, lineNumber: codeRef.lineNumbers[codeRef.requirementIds.indexOf(reqId)], verified: true, confidence: 1.0 }); } } // Find test references for (const [filePath, testRef] of this.testReferences) { if (testRef.requirementIds.includes(reqId)) { linkedItems.push({ type: 'test', path: filePath, verified: true, confidence: 1.0 }); } } // Calculate coverage const hasCode = linkedItems.some(item => item.type === 'code'); const hasTests = linkedItems.some(item => item.type === 'test'); const hasDocumentation = linkedItems.some(item => item.type === 'documentation'); const hasDeployment = linkedItems.some(item => item.type === 'deployment'); const completeness = (hasCode ? 0.4 : 0) + (hasTests ? 0.4 : 0) + (hasDocumentation ? 0.1 : 0) + (hasDeployment ? 0.1 : 0); links.set(reqId, { requirementId: reqId, type: req.type, linkedItems, coverage: { hasCode, hasTests, hasDocumentation, hasDeployment, completeness }, lastVerified: new Date() }); } return links; } /** * Verify links integrity */ async verifyLinks(links: Map<string, TraceabilityLink>): Promise<VerificationResult> { const issues: string[] = []; const warnings: string[] = []; for (const [reqId, link] of links) { // Check if requirement exists if (!this.requirements.has(reqId)) { issues.push(`Link references non-existent requirement: ${reqId}`); continue; } // Check if linked files exist for (const item of link.linkedItems) { try { await fs.access(item.path); } catch { issues.push(`Linked file not found: ${item.path} (for ${reqId})`); } } // Warn if no code if (!link.coverage.hasCode) { warnings.push(`Requirement ${reqId} has no code implementation`); } // Warn if no tests if (!link.coverage.hasTests) { warnings.push(`Requirement ${reqId} has no tests`); } } return { verified: issues.length === 0, issues, warnings }; } /** * Detect orphaned requirements, code, and tests */ async detectOrphans(): Promise<OrphanReport> { const orphanedRequirements: string[] = []; const orphanedCode: string[] = []; const orphanedTests: string[] = []; const severity = new Map<string, 'critical' | 'warning' | 'info'>(); // Find requirements without code for (const [reqId, req] of this.requirements) { const hasCode = Array.from(this.codeReferences.values()).some(ref => ref.requirementIds.includes(reqId) ); if (!hasCode) { orphanedRequirements.push(reqId); severity.set(reqId, req.priority === 'P0' ? 'critical' : req.priority === 'P1' ? 'warning' : 'info'); } } // Find code without requirements for (const [filePath, codeRef] of this.codeReferences) { if (codeRef.requirementIds.length === 0) { orphanedCode.push(filePath); severity.set(filePath, 'warning'); } } // Find tests without requirements for (const [filePath, testRef] of this.testReferences) { if (testRef.requirementIds.length === 0) { orphanedTests.push(filePath); severity.set(filePath, 'info'); } } return { orphanedRequirements, orphanedCode, orphanedTests, severity }; } /** * Calculate overall coverage */ async calculateCoverage(): Promise<CoverageReport> { const links = await this.buildTraceabilityLinks(); let totalRequirements = this.requirements.size; let tracedRequirements = 0; for (const link of links.values()) { if (link.coverage.hasCode && link.coverage.hasTests) { tracedRequirements++; } } const percentage = totalRequirements > 0 ? (tracedRequirements / totalRequirements) * 100 : 0; // Calculate coverage by type const byType = await this.calculateCoverageByType('use-case'); const nfrCoverage = await this.calculateCoverageByType('nfr'); byType.set('nfr', nfrCoverage.get('nfr') || 0); // Calculate coverage by priority const byPriority = new Map<string, number>(); const p0Reqs = Array.from(this.requirements.values()).filter(r => r.priority === 'P0'); const p1Reqs = Array.from(this.requirements.values()).filter(r => r.priority === 'P1'); const p2Reqs = Array.from(this.requirements.values()).filter(r => r.priority === 'P2'); byPriority.set('P0', this.calculateCoverageForRequirements(p0Reqs, links)); byPriority.set('P1', this.calculateCoverageForRequirements(p1Reqs, links)); byPriority.set('P2', this.calculateCoverageForRequirements(p2Reqs, links)); // Identify gaps const gaps = await this.identifyGaps(); return { percentage, byType, byPriority, gaps }; } /** * Calculate coverage by requirement type */ async calculateCoverageByType(type: 'use-case' | 'nfr'): Promise<Map<string, number>> { const links = await this.buildTraceabilityLinks(); const byType = new Map<string, number>(); const reqs = Array.from(this.requirements.values()).filter(r => r.type === type); const coverage = this.calculateCoverageForRequirements(reqs, links); byType.set(type, coverage); return byType; } /** * Identify gaps in traceability */ async identifyGaps(): Promise<GapAnalysis> { const links = await this.buildTraceabilityLinks(); const requirementsWithoutCode: string[] = []; const requirementsWithoutTests: string[] = []; const requirementsWithoutDocumentation: string[] = []; for (const [reqId, link] of links) { if (!link.coverage.hasCode) requirementsWithoutCode.push(reqId); if (!link.coverage.hasTests) requirementsWithoutTests.push(reqId); if (!link.coverage.hasDocumentation) requirementsWithoutDocumentation.push(reqId); } const codeWithoutRequirements = Array.from(this.codeReferences.keys()).filter( filePath => this.codeReferences.get(filePath)!.requirementIds.length === 0 ); const testsWithoutRequirements = Array.from(this.testReferences.keys()).filter( filePath => this.testReferences.get(filePath)!.requirementIds.length === 0 ); return { requirementsWithoutCode, requirementsWithoutTests, requirementsWithoutDocumentation, codeWithoutRequirements, testsWithoutRequirements }; } /** * Generate traceability matrix */ async generateMatrix(): Promise<TraceabilityMatrix> { const links = await this.buildTraceabilityLinks(); const requirements = Array.from(this.requirements.keys()); const codeFiles = Array.from(this.codeReferences.keys()); const testFiles = Array.from(this.testReferences.keys()); // Build links map const linksMap = new Map<string, { code: string[]; tests: string[] }>(); for (const [reqId, link] of links) { const code = link.linkedItems.filter(item => item.type === 'code').map(item => item.path); const tests = link.linkedItems.filter(item => item.type === 'test').map(item => item.path); linksMap.set(reqId, { code, tests }); } return this.matrixGenerator.generateMatrix(requirements, codeFiles, testFiles, linksMap); } /** * Export matrix to file */ async exportMatrix(format: 'csv' | 'excel' | 'markdown' | 'html', outputPath?: string): Promise<string> { const matrix = await this.generateMatrix(); const options: MatrixExportOptions = { format, includeVerification: true, includeConfidence: true }; let content: string; let defaultFilename: string; switch (format) { case 'csv': content = this.matrixGenerator.exportToCSV(matrix, options); defaultFilename = 'traceability-matrix.csv'; break; case 'excel': content = this.matrixGenerator.exportToExcel(matrix, options); defaultFilename = 'traceability-matrix.tsv'; break; case 'markdown': content = this.matrixGenerator.exportToMarkdown(matrix, options); defaultFilename = 'traceability-matrix.md'; break; case 'html': content = this.matrixGenerator.exportToHTML(matrix, options); defaultFilename = 'traceability-matrix.html'; break; } const filePath = outputPath || path.join(this.projectPath, '.aiwg/reports', defaultFilename); // Ensure directory exists await fs.mkdir(path.dirname(filePath), { recursive: true }); // Write file await fs.writeFile(filePath, content, 'utf-8'); return filePath; } /** * Generate comprehensive traceability report */ async generateReport(): Promise<TraceabilityReport> { const links = await this.buildTraceabilityLinks(); const orphans = await this.detectOrphans(); await this.identifyGaps(); const matrix = await this.generateMatrix(); const totalRequirements = this.requirements.size; let tracedRequirements = 0; for (const link of links.values()) { if (link.coverage.hasCode && link.coverage.hasTests) { tracedRequirements++; } } const coveragePercentage = totalRequirements > 0 ? (tracedRequirements / totalRequirements) * 100 : 0; // Build gaps by requirement const gapsByRequirement = new Map<string, string[]>(); for (const [reqId, link] of links) { const reqGaps: string[] = []; if (!link.coverage.hasCode) reqGaps.push('Missing code implementation'); if (!link.coverage.hasTests) reqGaps.push('Missing tests'); if (!link.coverage.hasDocumentation) reqGaps.push('Missing documentation'); if (reqGaps.length > 0) { gapsByRequirement.set(reqId, reqGaps); } } return { totalRequirements, tracedRequirements, coveragePercentage, orphanedRequirements: orphans.orphanedRequirements, orphanedCode: orphans.orphanedCode, orphanedTests: orphans.orphanedTests, gapsByRequirement, matrix }; } /** * Generate gap report (markdown format) */ async generateGapReport(): Promise<string> { const report = await this.generateReport(); const lines: string[] = []; lines.push('# Traceability Gap Report\n'); lines.push(`Generated: ${new Date().toISOString()}\n`); lines.push('## Summary\n'); lines.push(`- Total Requirements: ${report.totalRequirements}`); lines.push(`- Traced Requirements: ${report.tracedRequirements}`); lines.push(`- Coverage: ${report.coveragePercentage.toFixed(1)}%\n`); lines.push('## Orphaned Requirements\n'); if (report.orphanedRequirements.length > 0) { for (const reqId of report.orphanedRequirements) { const req = this.requirements.get(reqId); lines.push(`- **${reqId}** (${req?.priority || 'N/A'}): ${req?.title || 'Unknown'}`); } } else { lines.push('*None*\n'); } lines.push('\n## Orphaned Code\n'); if (report.orphanedCode.length > 0) { for (const filePath of report.orphanedCode) { lines.push(`- ${filePath}`); } } else { lines.push('*None*\n'); } lines.push('\n## Orphaned Tests\n'); if (report.orphanedTests.length > 0) { for (const filePath of report.orphanedTests) { lines.push(`- ${filePath}`); } } else { lines.push('*None*\n'); } lines.push('\n## Gaps by Requirement\n'); if (report.gapsByRequirement.size > 0) { for (const [reqId, gaps] of report.gapsByRequirement) { lines.push(`\n### ${reqId}\n`); for (const gap of gaps) { lines.push(`- ${gap}`); } } } else { lines.push('*None*\n'); } return lines.join('\n'); } /** * Export report to file */ async exportReport(format: 'markdown' | 'json' | 'html', outputPath?: string): Promise<string> { const report = await this.generateReport(); let content: string; let defaultFilename: string; switch (format) { case 'markdown': content = await this.generateGapReport(); defaultFilename = 'traceability-report.md'; break; case 'json': content = JSON.stringify( { ...report, gapsByRequirement: Array.from(report.gapsByRequirement.entries()) }, null, 2 ); defaultFilename = 'traceability-report.json'; break; case 'html': content = this.matrixGenerator.exportToHTML(report.matrix, { format: 'html' }); defaultFilename = 'traceability-report.html'; break; } const filePath = outputPath || path.join(this.projectPath, '.aiwg/reports', defaultFilename); // Ensure directory exists await fs.mkdir(path.dirname(filePath), { recursive: true }); // Write file await fs.writeFile(filePath, content, 'utf-8'); return filePath; } /** * Validate traceability against threshold */ async validateTraceability(threshold: number): Promise<ValidationResult> { const coverage = await this.calculateCoverage(); const passed = coverage.percentage >= threshold * 100; const issues: string[] = []; if (!passed) { issues.push(`Coverage ${coverage.percentage.toFixed(1)}% is below threshold ${(threshold * 100).toFixed(1)}%`); } // Check P0 requirements const p0Coverage = coverage.byPriority.get('P0') || 0; if (p0Coverage < 100) { issues.push(`P0 coverage ${p0Coverage.toFixed(1)}% is not 100%`); } return { passed, coverage: coverage.percentage, threshold: threshold * 100, issues }; } /** * Check Construction gate criteria (100% P0 requirements traced) */ async checkConstructionGate(): Promise<GateValidationResult> { const coverage = await this.calculateCoverage(); const p0Coverage = coverage.byPriority.get('P0') || 0; const p1Coverage = coverage.byPriority.get('P1') || 0; const issues: string[] = []; const warnings: string[] = []; // Critical: P0 must be 100% if (p0Coverage < 100) { const p0Reqs = Array.from(this.requirements.values()).filter(r => r.priority === 'P0'); const links = await this.buildTraceabilityLinks(); for (const req of p0Reqs) { const link = links.get(req.id); if (!link || !link.coverage.hasCode || !link.coverage.hasTests) { issues.push(`P0 requirement ${req.id} is not fully traced`); } } } // Warning: P1 should be high if (p1Coverage < 80) { warnings.push(`P1 coverage ${p1Coverage.toFixed(1)}% is below 80%`); } return { passed: p0Coverage === 100, p0Coverage, p1Coverage, issues, warnings }; } /** * Add a traceability link manually */ async addLink(requirementId: string, linkedItem: LinkedItem): Promise<void> { // Verify requirement exists if (!this.requirements.has(requirementId)) { throw new Error(`Requirement ${requirementId} not found`); } // Add to appropriate cache if (linkedItem.type === 'code') { const existing = this.codeReferences.get(linkedItem.path) || { filePath: linkedItem.path, requirementIds: [], lineNumbers: [] }; existing.requirementIds.push(requirementId); existing.lineNumbers.push(linkedItem.lineNumber || 0); this.codeReferences.set(linkedItem.path, existing); } else if (linkedItem.type === 'test') { const existing = this.testReferences.get(linkedItem.path) || { filePath: linkedItem.path, requirementIds: [], testNames: [] }; existing.requirementIds.push(requirementId); this.testReferences.set(linkedItem.path, existing); } } /** * Remove a traceability link */ async removeLink(requirementId: string, itemPath: string): Promise<void> { // Remove from code references const codeRef = this.codeReferences.get(itemPath); if (codeRef) { const index = codeRef.requirementIds.indexOf(requirementId); if (index !== -1) { codeRef.requirementIds.splice(index, 1); codeRef.lineNumbers.splice(index, 1); } } // Remove from test references const testRef = this.testReferences.get(itemPath); if (testRef) { const index = testRef.requirementIds.indexOf(requirementId); if (index !== -1) { testRef.requirementIds.splice(index, 1); } } } /** * Update a traceability link */ async updateLink(requirementId: string, itemPath: string, updates: Partial<LinkedItem>): Promise<void> { // For now, remove and re-add await this.removeLink(requirementId, itemPath); if (updates.type && updates.path) { await this.addLink(requirementId, updates as LinkedItem); } } // Helper methods private extractTitle(content: string, reqId: string): string { // Try to find title near the requirement ID const lines = content.split('\n'); for (let i = 0; i < lines.length; i++) { if (lines[i].includes(reqId)) { // Check surrounding lines for title const title = lines[i].match(/(?:Title|Name|Description):\s*(.+)/i); if (title) return title[1].trim(); // Check for markdown heading const heading = lines[i].match(/^#+\s+(.+)/); if (heading) return heading[1].trim(); // Check next line if (i + 1 < lines.length) { const nextHeading = lines[i + 1].match(/^#+\s+(.+)/); if (nextHeading) return nextHeading[1].trim(); } } } return 'Unknown'; } private extractPriority(content: string): 'P0' | 'P1' | 'P2' | undefined { const match = content.match(/\*?\*?Priority\*?\*?:\s*(P[012])/i); return match ? (match[1].toUpperCase() as 'P0' | 'P1' | 'P2') : undefined; } private extractTestNames(content: string): string[] { const testNames: string[] = []; const matches = content.matchAll(/(?:it|test)\s*\(\s*['"`](.+?)['"`]/g); for (const match of matches) { testNames.push(match[1]); } return testNames; } private calculateCoverageForRequirements( reqs: Requirement[], links: Map<string, TraceabilityLink> ): number { if (reqs.length === 0) return 0; let traced = 0; for (const req of reqs) { const link = links.get(req.id); if (link && link.coverage.hasCode && link.coverage.hasTests) { traced++; } } return (traced / reqs.length) * 100; } }