91 lines
5.3 KiB
JavaScript
91 lines
5.3 KiB
JavaScript
import { readFileSync, writeFileSync } from 'node:fs'
|
|
import { createHash } from 'node:crypto'
|
|
import { resolve, dirname } from 'node:path'
|
|
import { pathToFileURL } from 'node:url'
|
|
|
|
const levels = new Set(['unit', 'contract', 'http', 'browser', 'device'])
|
|
const digest = bytes => createHash('sha256').update(bytes).digest('hex')
|
|
const nonempty = value => typeof value === 'string' && value.trim().length > 0
|
|
|
|
// The reviewed manifest defines scope. Counts and free-text evidence never prove a requirement.
|
|
export function auditEvidence(manifest, load) {
|
|
if (!manifest || manifest.version !== 1 || !nonempty(manifest.scope)
|
|
|| !nonempty(manifest.revision) || !Array.isArray(manifest.requirements)
|
|
|| manifest.requirements.length === 0) throw new Error('Invalid requirement manifest')
|
|
const ids = new Set()
|
|
const results = manifest.requirements.map(requirement => {
|
|
const { id, domain, level, checks, evidence } = requirement
|
|
if (!nonempty(id) || ids.has(id) || !nonempty(domain) || !levels.has(level)
|
|
|| !Array.isArray(checks) || checks.length === 0 || checks.some(x => !nonempty(x))
|
|
|| new Set(checks).size !== checks.length) throw new Error('Invalid or duplicate requirement')
|
|
ids.add(id)
|
|
const reasons = []
|
|
if (!evidence || !nonempty(evidence.path) || !/^[a-f0-9]{64}$/.test(evidence.sha256 || '')) {
|
|
return { id, domain, level, passed: false, reasons: ['MISSING_EVIDENCE'] }
|
|
}
|
|
let report
|
|
try {
|
|
const bytes = load(evidence.path)
|
|
if (digest(bytes) !== evidence.sha256) reasons.push('HASH_MISMATCH')
|
|
report = JSON.parse(bytes.toString('utf8').replace(/^\uFEFF/, ''))
|
|
} catch {
|
|
return { id, domain, level, passed: false, reasons: ['UNREADABLE_EVIDENCE'] }
|
|
}
|
|
if (report.level !== level) reasons.push('EVIDENCE_LEVEL_MISMATCH')
|
|
if (report.domain !== domain) reasons.push('BUSINESS_DOMAIN_MISMATCH')
|
|
if (report.revision !== manifest.revision) reasons.push('REVISION_MISMATCH')
|
|
if (!nonempty(report.runId)) reasons.push('MISSING_RUN_ID')
|
|
if (!checkLegacyConsistency(report).passed) reasons.push('CONTRADICTORY_REPORT')
|
|
if (report.success !== true || report.fatal || report.completionClaim === false
|
|
|| report.summary?.allConditionsComplete === false
|
|
|| Number(report.summary?.failed || 0) > 0
|
|
|| Number(report.summary?.pendingConditions || 0) > 0
|
|
|| Number(report.summary?.skippedModules || 0) > 0) reasons.push('REPORT_NOT_COMPLETE')
|
|
if ((report.coverageGaps || []).some(gap => gap.blocking !== false)
|
|
|| Number(report.summary?.coverageGaps?.blocking || 0) > 0
|
|
|| (report.deploymentBlockers || []).length > 0) reasons.push('BLOCKING_GAP')
|
|
const steps = Array.isArray(report.steps) ? report.steps : []
|
|
if (steps.some(step => step.passed !== true || step.skipped)) {
|
|
reasons.push('FAILED_OR_SKIPPED_STEP')
|
|
}
|
|
for (const name of checks) {
|
|
const matches = steps.filter(step => step.name === name && step.requirementId === id)
|
|
if (matches.length !== 1 || matches[0].passed !== true) reasons.push(`CHECK_NOT_PROVEN:${name}`)
|
|
else if (['http', 'browser'].includes(level) && (!nonempty(matches[0].requestId)
|
|
|| !nonempty(matches[0].marker) || matches[0].marker !== report.runId)) {
|
|
reasons.push(`MISSING_RUNTIME_IDENTITY:${name}`)
|
|
}
|
|
}
|
|
return { id, domain, level, passed: reasons.length === 0, reasons }
|
|
})
|
|
const passed = results.filter(row => row.passed).length
|
|
return { version: 1, scope: manifest.scope, revision: manifest.revision,
|
|
generatedAt: new Date().toISOString(), completionClaim: passed === results.length,
|
|
summary: { requirements: results.length, passed, pending: results.length - passed }, results }
|
|
}
|
|
|
|
export function checkLegacyConsistency(report) {
|
|
const reasons = []
|
|
if (report.summary?.allConditionsComplete === true && report.completionClaim !== true) reasons.push('CONTRADICTORY_COMPLETION')
|
|
if (report.summary?.allConditionsComplete === true
|
|
&& report.summary?.checkedScopeEvidence !== report.summary?.requirements) reasons.push('INCOMPLETE_CHECKED_SCOPE')
|
|
if (report.summary?.allConditionsComplete === true && (report.conditions || []).some(row => row.remaining)) reasons.push('REMAINING_REQUIREMENTS')
|
|
return { passed: reasons.length === 0, reasons,
|
|
scope: 'Consistency only; legacy report is not promoted to verified evidence' }
|
|
}
|
|
|
|
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
|
|
try {
|
|
const [mode, input, output] = process.argv.slice(2)
|
|
if (!['audit', 'check-legacy'].includes(mode) || !input || !output) throw new Error('Usage: acceptance-evidence-audit.mjs audit|check-legacy <input.json> <new-output.json>')
|
|
const source = JSON.parse(readFileSync(input, 'utf8').replace(/^\uFEFF/, ''))
|
|
const result = mode === 'audit' ? auditEvidence(source, file => readFileSync(resolve(dirname(input), file))) : checkLegacyConsistency(source)
|
|
writeFileSync(output, JSON.stringify(result, null, 2) + '\n', { flag: 'wx' })
|
|
console.log(JSON.stringify(result.summary || { passed: result.passed, reasons: result.reasons }))
|
|
process.exitCode = (mode === 'audit' ? result.completionClaim : result.passed) ? 0 : 1
|
|
} catch (error) {
|
|
console.error(error.code === 'EEXIST' ? 'Output already exists; preserve prior evidence' : 'Evidence audit failed: invalid arguments, manifest or file')
|
|
process.exitCode = 2
|
|
}
|
|
}
|