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
text/typescript
/**
* 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;
}
}