86 lines
7.2 KiB
JavaScript
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; }
|
|
}
|