Files
github-actions[bot] 38af5bcb89 chore: publish from main
2026-08-14 01:24:17 +00:00

153 lines
7.7 KiB
JavaScript

import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
const statuses = new Set(['verified', 'partial', 'blocked'])
const evidenceSources = new Set(['executed-now', 'supplied', 'mixed'])
const baselineResults = new Set(['failed', 'observed', 'not-run'])
const verificationResults = new Set(['passed', 'failed', 'not-run'])
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
const nonEmpty = (value) => typeof value === 'string' && value.trim().length > 0
export const sampleReceipt = {
version: 2,
status: 'verified',
evidenceSource: 'executed-now',
problem: 'A 10% checkout discount returns 100 instead of 90 after currency rounding.',
baseline: {
command: 'npm test -- discount.test.ts',
result: 'failed',
evidence: 'Expected 90, received 100.',
},
rootCause: {
summary: 'The subtotal was rounded before the percentage discount was applied.',
evidence: [{ location: 'src/pricing.ts:42', observation: 'roundCurrency(subtotal) was passed into applyDiscount().' }],
},
changes: [{ file: 'src/pricing.ts', summary: 'Apply the discount to the subtotal before currency rounding.' }],
verification: [
{ command: 'npm test -- discount.test.ts', result: 'passed', evidence: '1 test passed.' },
{ command: 'npm test', result: 'passed', evidence: '42 tests passed.' },
],
gaps: [],
}
export function validateReceipt(receipt) {
const issues = []
const add = (path, message) => issues.push({ path, message })
const rejectUnknown = (value, allowed, path) => {
if (!isObject(value)) return
for (const key of Object.keys(value)) {
if (!allowed.has(key)) add(path ? `${path}.${key}` : key, 'Unknown field.')
}
}
if (!isObject(receipt)) return { valid: false, issues: [{ path: '$', message: 'Receipt must be a JSON object.' }] }
rejectUnknown(receipt, new Set(['version', 'status', 'evidenceSource', 'problem', 'baseline', 'rootCause', 'changes', 'verification', 'gaps']), '')
if (receipt.version !== 1 && receipt.version !== 2) add('version', 'Must equal 1 or 2.')
if (!statuses.has(receipt.status)) add('status', 'Must be verified, partial, or blocked.')
if (receipt.evidenceSource !== undefined && !evidenceSources.has(receipt.evidenceSource)) add('evidenceSource', 'Must be executed-now, supplied, or mixed.')
if (receipt.version === 2 && !evidenceSources.has(receipt.evidenceSource)) add('evidenceSource', 'Version 2 requires an evidence source.')
if (!nonEmpty(receipt.problem)) add('problem', 'Must be a non-empty string.')
if (!isObject(receipt.baseline)) {
add('baseline', 'Must be an object.')
} else {
rejectUnknown(receipt.baseline, new Set(['command', 'result', 'evidence']), 'baseline')
if (!nonEmpty(receipt.baseline.command)) add('baseline.command', 'Must be a non-empty string.')
if (!baselineResults.has(receipt.baseline.result)) add('baseline.result', 'Must be failed, observed, or not-run.')
if (!nonEmpty(receipt.baseline.evidence)) add('baseline.evidence', 'Must be a non-empty string.')
}
if (!isObject(receipt.rootCause)) {
add('rootCause', 'Must be an object.')
} else {
rejectUnknown(receipt.rootCause, new Set(['summary', 'evidence']), 'rootCause')
if (!nonEmpty(receipt.rootCause.summary)) add('rootCause.summary', 'Must be a non-empty string.')
if (!Array.isArray(receipt.rootCause.evidence)) {
add('rootCause.evidence', 'Must be an array.')
} else {
receipt.rootCause.evidence.forEach((entry, index) => {
if (!isObject(entry)) return add(`rootCause.evidence[${index}]`, 'Must be an object.')
rejectUnknown(entry, new Set(['location', 'observation']), `rootCause.evidence[${index}]`)
if (!nonEmpty(entry.location)) add(`rootCause.evidence[${index}].location`, 'Must be a non-empty string.')
if (!nonEmpty(entry.observation)) add(`rootCause.evidence[${index}].observation`, 'Must be a non-empty string.')
})
}
}
if (!Array.isArray(receipt.changes)) {
add('changes', 'Must be an array.')
} else {
receipt.changes.forEach((entry, index) => {
if (!isObject(entry)) return add(`changes[${index}]`, 'Must be an object.')
rejectUnknown(entry, new Set(['file', 'summary']), `changes[${index}]`)
if (!nonEmpty(entry.file)) add(`changes[${index}].file`, 'Must be a non-empty string.')
if (!nonEmpty(entry.summary)) add(`changes[${index}].summary`, 'Must be a non-empty string.')
})
}
if (!Array.isArray(receipt.verification)) {
add('verification', 'Must be an array.')
} else {
receipt.verification.forEach((entry, index) => {
if (!isObject(entry)) return add(`verification[${index}]`, 'Must be an object.')
rejectUnknown(entry, new Set(['command', 'result', 'evidence']), `verification[${index}]`)
if (!nonEmpty(entry.command)) add(`verification[${index}].command`, 'Must be a non-empty string.')
if (!verificationResults.has(entry.result)) add(`verification[${index}].result`, 'Must be passed, failed, or not-run.')
if (!nonEmpty(entry.evidence)) add(`verification[${index}].evidence`, 'Must be a non-empty string.')
})
}
if (!Array.isArray(receipt.gaps) || receipt.gaps.some((gap) => !nonEmpty(gap))) add('gaps', 'Must be an array of non-empty strings.')
if (receipt.status === 'verified') {
if (receipt.baseline?.result === 'not-run') add('baseline.result', 'Verified requires an observed baseline.')
if (!Array.isArray(receipt.rootCause?.evidence) || receipt.rootCause.evidence.length === 0) add('rootCause.evidence', 'Verified requires concrete root-cause evidence.')
if (!Array.isArray(receipt.changes) || receipt.changes.length === 0) add('changes', 'Verified requires at least one changed file or artifact.')
if (!Array.isArray(receipt.verification) || receipt.verification.length === 0) add('verification', 'Verified requires at least one verification check.')
if (Array.isArray(receipt.verification) && receipt.verification.some((entry) => entry?.result !== 'passed')) add('verification', 'Every verification check must pass for verified status.')
if (Array.isArray(receipt.gaps) && receipt.gaps.length > 0) add('gaps', 'Verified status cannot contain proof gaps.')
}
if (receipt.status === 'partial' && Array.isArray(receipt.gaps) && receipt.gaps.length === 0) add('gaps', 'Partial status must name at least one missing proof layer.')
if (receipt.status === 'blocked' && Array.isArray(receipt.gaps) && receipt.gaps.length === 0) add('gaps', 'Blocked status must name the external blocking condition.')
return { valid: issues.length === 0, issues }
}
async function main() {
const path = process.argv[2]
if (!path) throw new Error('Usage: node scripts/validate-receipt.mjs <receipt.json> [--json]')
let input = ''
if (path === '-') {
process.stdin.setEncoding('utf8')
for await (const chunk of process.stdin) input += chunk
} else {
input = await readFile(resolve(path), 'utf8')
}
const receipt = JSON.parse(input)
const result = validateReceipt(receipt)
if (process.argv.includes('--json')) {
process.stdout.write(`${JSON.stringify(result)}\n`)
} else if (result.valid) {
process.stdout.write(`✓ ${path} is a valid ${receipt.status.toUpperCase()} bug receipt.\n`)
} else {
process.stderr.write(`✗ ${path} is not a valid bug receipt:\n`)
for (const issue of result.issues) process.stderr.write(` ${issue.path}: ${issue.message}\n`)
}
process.exitCode = result.valid ? 0 : 1
}
const invokedUrl = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''
if (import.meta.url === invokedUrl) {
main().catch((error) => {
process.stderr.write(`bug-receipt: ${error.message}\n`)
process.exitCode = 2
})
}