|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +/** |
| 4 | + * Validate a certification evidence manifest against IDD certification thresholds. |
| 5 | + * |
| 6 | + * Usage: |
| 7 | + * node tools/validate-evidence.js --evidence certification/foo/evidence.yaml |
| 8 | + * |
| 9 | + * Options: |
| 10 | + * --evidence <path> Required evidence manifest path |
| 11 | + * --allow-gaps Allow non-empty gaps list without failing |
| 12 | + * --json Output machine-readable results |
| 13 | + * |
| 14 | + * Exit: 0 = pass, 1 = errors found |
| 15 | + */ |
| 16 | + |
| 17 | +const fs = require('fs'); |
| 18 | +const path = require('path'); |
| 19 | +const yaml = require('js-yaml'); |
| 20 | +const { fileExists, formatResults } = require('./lib/parse-front-matter'); |
| 21 | + |
| 22 | +const args = process.argv.slice(2); |
| 23 | +let evidencePath = null; |
| 24 | +let allowGaps = false; |
| 25 | +let jsonOutput = false; |
| 26 | + |
| 27 | +for (let i = 0; i < args.length; i += 1) { |
| 28 | + const arg = args[i]; |
| 29 | + if (arg === '--evidence') { |
| 30 | + evidencePath = path.resolve(args[i + 1]); |
| 31 | + i += 1; |
| 32 | + } else if (arg === '--allow-gaps') { |
| 33 | + allowGaps = true; |
| 34 | + } else if (arg === '--json') { |
| 35 | + jsonOutput = true; |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +const results = { |
| 40 | + errors: [], |
| 41 | + warnings: [], |
| 42 | + info: [], |
| 43 | +}; |
| 44 | + |
| 45 | +function toProjectRelative(absolutePath) { |
| 46 | + return path.relative(process.cwd(), absolutePath).replace(/\\/g, '/'); |
| 47 | +} |
| 48 | + |
| 49 | +function parseYaml(filePath) { |
| 50 | + return yaml.load(fs.readFileSync(filePath, 'utf8')); |
| 51 | +} |
| 52 | + |
| 53 | +function isObject(value) { |
| 54 | + return value && typeof value === 'object' && !Array.isArray(value); |
| 55 | +} |
| 56 | + |
| 57 | +function parseRatio(value) { |
| 58 | + if (typeof value !== 'string') return null; |
| 59 | + const match = value.match(/^(\d+)\s*\/\s*(\d+)$/); |
| 60 | + if (!match) return null; |
| 61 | + return { |
| 62 | + covered: Number(match[1]), |
| 63 | + total: Number(match[2]), |
| 64 | + }; |
| 65 | +} |
| 66 | + |
| 67 | +function checkRequiredField(obj, fieldPath, label = fieldPath) { |
| 68 | + const value = fieldPath.split('.').reduce((current, key) => { |
| 69 | + if (!isObject(current)) return undefined; |
| 70 | + return current[key]; |
| 71 | + }, obj); |
| 72 | + |
| 73 | + if (value === undefined || value === null || value === '') { |
| 74 | + results.errors.push(`Missing required field: ${label}`); |
| 75 | + return undefined; |
| 76 | + } |
| 77 | + |
| 78 | + return value; |
| 79 | +} |
| 80 | + |
| 81 | +function checkReportPath(evidenceDir, sectionName, reportPath) { |
| 82 | + if (reportPath == null || reportPath === '') { |
| 83 | + results.warnings.push(`${sectionName}: No report path recorded`); |
| 84 | + return; |
| 85 | + } |
| 86 | + |
| 87 | + const resolved = path.resolve(evidenceDir, reportPath); |
| 88 | + if (!fs.existsSync(resolved)) { |
| 89 | + results.errors.push(`${sectionName}: Report file not found: ${reportPath}`); |
| 90 | + } |
| 91 | +} |
| 92 | + |
| 93 | +function checkFailedCount(sectionName, section) { |
| 94 | + if (!isObject(section)) { |
| 95 | + results.errors.push(`${sectionName}: Evidence section is missing or invalid`); |
| 96 | + return; |
| 97 | + } |
| 98 | + |
| 99 | + if (section.failed == null) { |
| 100 | + results.warnings.push(`${sectionName}: Failed count is not recorded`); |
| 101 | + return; |
| 102 | + } |
| 103 | + |
| 104 | + if (typeof section.failed !== 'number') { |
| 105 | + results.errors.push(`${sectionName}: Failed count must be numeric`); |
| 106 | + return; |
| 107 | + } |
| 108 | + |
| 109 | + if (section.failed > 0) { |
| 110 | + results.errors.push(`${sectionName}: Failed count must be 0 (found ${section.failed})`); |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +function validateTraceabilitySection(traceability) { |
| 115 | + if (!isObject(traceability)) { |
| 116 | + results.errors.push('Missing required field: traceability'); |
| 117 | + return; |
| 118 | + } |
| 119 | + |
| 120 | + const ratioFields = [ |
| 121 | + 'stories_with_features', |
| 122 | + 'features_with_contracts', |
| 123 | + 'endpoints_with_tests', |
| 124 | + 'journeys_with_e2e', |
| 125 | + ]; |
| 126 | + |
| 127 | + for (const field of ratioFields) { |
| 128 | + const value = traceability[field]; |
| 129 | + if (value == null || value === '') { |
| 130 | + results.errors.push(`traceability.${field}: Missing required value`); |
| 131 | + continue; |
| 132 | + } |
| 133 | + |
| 134 | + const parsed = parseRatio(value); |
| 135 | + if (!parsed) { |
| 136 | + results.errors.push(`traceability.${field}: Expected X/Y ratio, found '${value}'`); |
| 137 | + continue; |
| 138 | + } |
| 139 | + |
| 140 | + if (parsed.covered !== parsed.total) { |
| 141 | + results.errors.push(`traceability.${field}: Expected 100% coverage, found ${value}`); |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + const orphanFields = [ |
| 146 | + 'orphan_tests', |
| 147 | + 'orphan_features', |
| 148 | + 'orphan_endpoints', |
| 149 | + ]; |
| 150 | + |
| 151 | + for (const field of orphanFields) { |
| 152 | + const value = traceability[field]; |
| 153 | + if (value == null) { |
| 154 | + results.errors.push(`traceability.${field}: Missing required value`); |
| 155 | + continue; |
| 156 | + } |
| 157 | + if (typeof value !== 'number') { |
| 158 | + results.errors.push(`traceability.${field}: Expected numeric value`); |
| 159 | + continue; |
| 160 | + } |
| 161 | + if (value !== 0) { |
| 162 | + results.errors.push(`traceability.${field}: Expected 0, found ${value}`); |
| 163 | + } |
| 164 | + } |
| 165 | +} |
| 166 | + |
| 167 | +function validateGaps(gaps) { |
| 168 | + if (!Array.isArray(gaps)) { |
| 169 | + results.errors.push('Missing required field: gaps'); |
| 170 | + return; |
| 171 | + } |
| 172 | + |
| 173 | + if (gaps.length === 0) { |
| 174 | + results.info.push('No declared certification gaps'); |
| 175 | + return; |
| 176 | + } |
| 177 | + |
| 178 | + if (allowGaps) { |
| 179 | + results.warnings.push(`Certification includes ${gaps.length} declared gap(s); allowed via --allow-gaps`); |
| 180 | + } else { |
| 181 | + results.errors.push(`Certification includes ${gaps.length} declared gap(s); use --allow-gaps only when human approval exists`); |
| 182 | + } |
| 183 | +} |
| 184 | + |
| 185 | +function main() { |
| 186 | + if (!evidencePath) { |
| 187 | + results.errors.push('Missing required option: --evidence <path>'); |
| 188 | + return finish(); |
| 189 | + } |
| 190 | + |
| 191 | + if (!fs.existsSync(evidencePath)) { |
| 192 | + results.errors.push(`Evidence file not found: ${toProjectRelative(evidencePath)}`); |
| 193 | + return finish(); |
| 194 | + } |
| 195 | + |
| 196 | + let evidence; |
| 197 | + try { |
| 198 | + evidence = parseYaml(evidencePath); |
| 199 | + } catch (error) { |
| 200 | + results.errors.push(`Failed to parse evidence file: ${error.message}`); |
| 201 | + return finish(); |
| 202 | + } |
| 203 | + |
| 204 | + if (!isObject(evidence)) { |
| 205 | + results.errors.push('Evidence manifest is empty or invalid'); |
| 206 | + return finish(); |
| 207 | + } |
| 208 | + |
| 209 | + const capabilityRef = checkRequiredField(evidence, 'capability'); |
| 210 | + checkRequiredField(evidence, 'certified_at'); |
| 211 | + checkRequiredField(evidence, 'certified_by'); |
| 212 | + const evidenceSection = checkRequiredField(evidence, 'evidence'); |
| 213 | + const traceabilitySection = checkRequiredField(evidence, 'traceability'); |
| 214 | + const gapsSection = checkRequiredField(evidence, 'gaps'); |
| 215 | + |
| 216 | + if (typeof capabilityRef === 'string') { |
| 217 | + if (!fileExists(capabilityRef)) { |
| 218 | + results.errors.push(`Referenced capability file not found: ${capabilityRef}`); |
| 219 | + } else { |
| 220 | + results.info.push(`Referenced capability found: ${capabilityRef}`); |
| 221 | + } |
| 222 | + } |
| 223 | + |
| 224 | + const evidenceDir = path.dirname(evidencePath); |
| 225 | + if (isObject(evidenceSection)) { |
| 226 | + const unitTests = evidenceSection.unit_tests; |
| 227 | + const contractTests = evidenceSection.contract_tests; |
| 228 | + const e2eTests = evidenceSection.e2e_tests; |
| 229 | + const coverage = evidenceSection.coverage; |
| 230 | + |
| 231 | + checkFailedCount('evidence.unit_tests', unitTests); |
| 232 | + checkFailedCount('evidence.contract_tests', contractTests); |
| 233 | + checkFailedCount('evidence.e2e_tests', e2eTests); |
| 234 | + |
| 235 | + checkReportPath(evidenceDir, 'evidence.unit_tests', unitTests?.report); |
| 236 | + checkReportPath(evidenceDir, 'evidence.contract_tests', contractTests?.report); |
| 237 | + checkReportPath(evidenceDir, 'evidence.e2e_tests', e2eTests?.report); |
| 238 | + |
| 239 | + if (coverage?.report) { |
| 240 | + checkReportPath(evidenceDir, 'evidence.coverage', coverage.report); |
| 241 | + } else { |
| 242 | + results.info.push('Coverage report not recorded (informational only)'); |
| 243 | + } |
| 244 | + } |
| 245 | + |
| 246 | + validateTraceabilitySection(traceabilitySection); |
| 247 | + validateGaps(gapsSection); |
| 248 | + |
| 249 | + finish(); |
| 250 | +} |
| 251 | + |
| 252 | +function finish() { |
| 253 | + if (jsonOutput) { |
| 254 | + console.log(JSON.stringify(results, null, 2)); |
| 255 | + } else { |
| 256 | + console.log(formatResults(results)); |
| 257 | + } |
| 258 | + |
| 259 | + process.exit(results.errors.length > 0 ? 1 : 0); |
| 260 | +} |
| 261 | + |
| 262 | +main(); |
0 commit comments