Files
HMDesign/scripts/guard-evidence.mjs

86 lines
7.2 KiB
JavaScript

import fs from 'node:fs';
import path from 'node:path';
import { createHash } from 'node:crypto';
import { fileURLToPath } from 'node:url';
import { styleVerification } from './guard-style-evidence.mjs';
export function sourceFingerprint(target, files) {
const hash = createHash('sha256');
for (const file of [...new Set(files)].sort()) {
hash.update(file.replaceAll('\\', '/')); hash.update('\0');
hash.update(fs.readFileSync(path.resolve(target, file))); hash.update('\0');
}
return hash.digest('hex');
}
// Optional measurements describe styles, never approval of a consumer override.
export function linkCssEvidence(guard, runtime, linked) {
const supplied = runtime.cssEvidence;
if (supplied === undefined) return { status: 'NOT VERIFIED', checks: [], intentReviews: [], reasons: ['CSS measurements were not supplied'] };
if (!linked) return { status: 'NOT VERIFIED', checks: [], intentReviews: [], reasons: ['CSS evidence is stale or unlinked'] };
if (!supplied || typeof supplied !== 'object') return { status: 'NOT VERIFIED', checks: [], intentReviews: [], reasons: ['Invalid CSS evidence object'] };
const reasons = [];
const checks = [];
const intentReviews = [];
if (!Array.isArray(supplied.checks) || !supplied.checks.length) reasons.push('Named CSS measurements are missing');
for (const check of Array.isArray(supplied.checks) ? supplied.checks : []) {
const valid = check && typeof check.name === 'string' && check.name.trim() &&
['COMPUTED_STYLE', 'CSS_VARIABLE'].includes(check.kind) &&
[390, 768, 1280, 1440].includes(check.width) && runtime.viewports?.some(v => v.width === check.width && v.status === 'PASS') &&
typeof check.selector === 'string' && check.selector.trim() && typeof check.property === 'string' && check.property.trim() &&
(check.kind !== 'CSS_VARIABLE' || check.property.startsWith('--')) &&
typeof check.actual === 'string' && check.actual.trim() && typeof check.expected === 'string' && check.expected.trim();
if (!valid) { reasons.push('Incomplete or unsupported CSS measurement'); continue; }
checks.push({ ...check, status: check.actual === check.expected ? 'PASS' : 'FAIL' });
}
for (const review of Array.isArray(supplied.intentReviews) ? supplied.intentReviews : []) {
const issue = guard.issues.find(i => i.code === 'REUSE-003' && i.code === review?.issue?.code && i.file === review.issue.file && i.line === review.issue.line);
const reference = review?.reference;
let matches = false;
// References stay inside the explicit contract source, including real paths.
try {
const root = fs.realpathSync(guard.contractSource);
const file = fs.realpathSync(path.resolve(root, reference.file));
const relative = path.relative(root, file);
matches = relative !== '..' && !relative.startsWith('..' + path.sep) && !path.isAbsolute(relative) &&
createHash('sha256').update(fs.readFileSync(file)).digest('hex') === reference.sha256;
} catch { /* Missing / stale references remain unverified. */ }
let lineCount = 0;
try { lineCount = fs.readFileSync(path.resolve(guard.contractSource, reference.file), 'utf8').split('\n').length; } catch { /* Invalid reference. */ }
if (!issue || !matches || typeof review.rationale !== 'string' || !review.rationale.trim() || !Number.isInteger(reference?.line) || reference.line < 1 || reference.line > lineCount) {
reasons.push('CSS intent review has an unmatched issue or unavailable / stale reference'); continue;
}
intentReviews.push({ issue: review.issue, rationale: review.rationale, reference, status: 'NOT VERIFIED', basis: 'Documented usage review; design intent is not automatically approved' });
}
return { status: checks.some(c => c.status === 'FAIL') ? 'FAIL' : checks.length && !reasons.length ? 'PASS' : 'NOT VERIFIED', checks, intentReviews, reasons };
}
// Combines independent results without altering the original Guard issues.
export function combineEvidence(guard, runtime) {
const reasons = [];
let fresh = false;
try { fresh = guard.sourceFingerprint === sourceFingerprint(guard.target, guard.fingerprintFiles) && guard.contractFingerprint === sourceFingerprint(guard.contractSource, guard.contractFiles); }
catch { reasons.push('Guard source or contract files are unavailable'); }
const linked = fresh && runtime.sourceFingerprint === guard.sourceFingerprint && runtime.contractFingerprint === guard.contractFingerprint;
if (!linked) reasons.push('Runtime evidence is stale or does not match the Guard source / contract');
const expected = [390, 768, 1280, 1440];
const viewportComplete = expected.every(width => runtime.viewports?.some(v => v.width === width && v.status === 'PASS'));
const interactionComplete = Array.isArray(runtime.interactions) && runtime.interactions.length > 0 && runtime.interactions.every(i => i.status === 'PASS' && typeof i.name === 'string' && i.name.trim());
const cssEvidence = linkCssEvidence(guard, runtime, linked);
const styles = styleVerification(guard, runtime, cssEvidence, linked);
const failed = runtime.viewports?.some(v => v.status === 'FAIL') || runtime.interactions?.some(i => i.status === 'FAIL') || cssEvidence.status === 'FAIL' || styles.runtimeStatus === 'FAIL';
const provenBootstrap = guard.runtimeRequired === true && !guard.issues.some(i => i.code === 'RENDER-001');
if (!provenBootstrap) reasons.push('Final React bootstrap was not proven with --result');
const runtimeStatus = !linked || !provenBootstrap ? 'NOT VERIFIED' : failed ? 'FAIL' : viewportComplete && interactionComplete ? 'PASS' : 'NOT VERIFIED';
if (!viewportComplete) reasons.push('Four required viewport results are missing or incomplete');
if (!interactionComplete) reasons.push('Named core interaction checks are missing or incomplete');
const status = guard.issues.some(i => i.severity === 'FAIL') || guard.staticStatus === 'FAIL' || runtimeStatus === 'FAIL' || styles.finalStatus === 'FAIL' ? 'FAIL' : guard.staticStatus === 'PASS' && styles.staticStatus === 'PASS' && runtimeStatus === 'PASS' && (!guard.styleEvidence || styles.designIntentStatus === 'PASS') ? 'PASS' : 'NOT VERIFIED';
return { schemaVersion: 1, scope: guard.scope, status, staticStatus: guard.staticStatus, rawGuardStatus: guard.status, runtimeStatus, linked, reasons, cssEvidence, styleVerification: styles, unresolvedStaticIssues: guard.issues.filter(i => !i.code.startsWith('RENDER-')), limitations: ['Scope is inherited from Guard; PARTIAL never covers the entire screen', 'Native/CSS intent findings are not cleared by browser evidence', 'Runtime assertions are supplied evidence, not independent attestation', 'CSS measurements cover only the named selector / property / viewport; they do not approve design intent or dynamic token states', 'Screen reader speech, physical touch, backend and all states are NOT VERIFIED'] };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
const [guardFile, runtimeFile] = process.argv.slice(2);
const result = combineEvidence(JSON.parse(fs.readFileSync(guardFile)), JSON.parse(fs.readFileSync(runtimeFile)));
console.log(JSON.stringify(result, null, 2));
if (result.status === 'FAIL') process.exitCode = 1;
} catch (error) { console.error(error.message); process.exitCode = 2; }
}