Files
jobtrackingapp/docs/automation/handoffs/pre-phase10/verify.mjs
T

225 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Evidence validation only. Protected approvals and execution belong to the separate controller.
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import zlib from 'node:zlib';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
const directory = path.dirname(fileURLToPath(import.meta.url));
const root = path.resolve(directory, '../../../..');
const dependencies = createRequire(path.join(root, 'job-tracker-ui/package.json'));
const Ajv = dependencies('ajv');
const { JSDOM } = dependencies('jsdom');
const digest = bytes => crypto.createHash('sha256').update(bytes).digest('hex');
const demand = (condition, message) => { if (!condition) throw new Error(message); };
export function verifyBytes(bytes, expected) {
demand(Buffer.isBuffer(bytes) && /^[a-f0-9]{64}$/.test(expected) && digest(bytes) === expected, 'Missing or changed evidence');
}
export function measureNative(format, text) {
demand(Buffer.byteLength(text, 'utf8') <= 8 * 1024 * 1024, 'Native report exceeds its input bound');
const result = { passed: 0, failed: 0, skipped: 0, retries: 0, unexecuted: 0, names: [] };
if (format === 'trx') {
demand(!/<!DOCTYPE|<!ENTITY/i.test(text) && (text.match(/</g)?.length ?? 0) < 100000, 'XML declarations or excessive structure are not accepted');
const document = new JSDOM(text, { contentType: 'text/xml' }).window.document;
const counters = document.getElementsByTagNameNS('*', 'Counters')[0];
demand(counters, 'Missing native TRX counters');
for (const item of document.getElementsByTagNameNS('*', 'UnitTestResult')) {
result.names.push(item.getAttribute('testName'));
const outcome = item.getAttribute('outcome');
if (outcome === 'Passed') result.passed++;
else if (outcome === 'NotExecuted') result.skipped++;
else result.failed++;
}
demand(Number(counters.getAttribute('total')) === result.names.length && Number(counters.getAttribute('passed')) === result.passed && Number(counters.getAttribute('failed')) === result.failed, 'TRX membership/counters disagree');
} else if (format === 'junit') {
demand(!/<!DOCTYPE|<!ENTITY/i.test(text) && (text.match(/</g)?.length ?? 0) < 100000, 'XML declarations or excessive structure are not accepted');
const document = new JSDOM(text, { contentType: 'text/xml' }).window.document;
for (const item of document.getElementsByTagName('testcase')) {
result.names.push(`${item.getAttribute('classname')}::${item.getAttribute('name')}`);
if (item.getElementsByTagName('skipped').length) result.skipped++;
else if (item.getElementsByTagName('failure').length || item.getElementsByTagName('error').length) result.failed++;
else result.passed++;
}
} else if (format === 'jest') {
const report = JSON.parse(text);
for (const suite of report.testResults ?? []) for (const item of suite.assertionResults ?? []) {
const name = suite.name.replaceAll('\\', '/');
const marker = name.lastIndexOf('/job-tracker-ui/');
result.names.push(`${marker < 0 ? name : name.slice(marker + 1)}::${item.fullName}`);
if (item.status === 'passed') result.passed++;
else if (['pending', 'skipped', 'todo', 'disabled'].includes(item.status)) result.skipped++;
else result.failed++;
}
demand(report.numTotalTests === result.names.length && report.numPassedTests === result.passed && report.numFailedTests === result.failed && report.numPendingTests === result.skipped && !report.numRuntimeErrorTestSuites, 'Jest membership/counters disagree');
} else if (format === 'playwright') {
const visit = suite => {
for (const spec of suite.specs ?? []) for (const item of spec.tests ?? []) {
result.names.push(`${spec.id}::${item.projectName}`);
const attempts = item.results ?? [];
result.retries += Math.max(0, attempts.length - 1);
if (!attempts.length) result.unexecuted++;
else if (attempts.at(-1).status === 'skipped') result.skipped++;
else if (attempts.at(-1).status === 'passed' && item.expectedStatus !== 'failed' && item.status === 'expected') result.passed++;
else result.failed++;
if (attempts.some(attempt => (attempt.retry ?? 0) > 0)) result.retries = Math.max(1, result.retries);
}
for (const child of suite.suites ?? []) visit(child);
};
const report = JSON.parse(text);
demand(!(report.errors?.length), 'Browser report contains execution errors');
for (const suite of report.suites ?? []) visit(suite);
} else if (format === 'pytest') {
result.passed = Number(text.match(/\b(\d+) passed\b/)?.[1] ?? 0);
result.failed = Number(text.match(/\b(\d+) failed\b/)?.[1] ?? 0);
result.skipped = Number(text.match(/\b(\d+) skipped\b/)?.[1] ?? 0);
} else if (format === 'unittest') {
const count = Number(text.match(/Ran (\d+) tests? in/)?.[1] ?? 0);
if (/^OK\s*$/m.test(text)) result.passed = count;
else result.failed = count || 1;
result.skipped = Number(text.match(/skipped=(\d+)/)?.[1] ?? 0);
result.names = [...text.matchAll(/^(test\S+ \([^\n]+\)) \.\.\. /gm)].map(match => match[1]);
} else if (format === 'node-test') {
result.passed = Number(text.match(/(?:#|ℹ) pass (\d+)/u)?.[1] ?? 0);
result.failed = Number(text.match(/(?:#|ℹ) fail (\d+)/u)?.[1] ?? 0);
result.skipped = Number(text.match(/(?:#|ℹ) skipped (\d+)/u)?.[1] ?? 0);
result.names = [...text.matchAll(/^(?:✔|✖|ok \d+ -|not ok \d+ -) (.+?)(?: \([\d.]+ms\))?$/gmu)].map(match => match[1]);
} else throw new Error('Unknown native report format');
result.names.sort();
result.membershipSha256 = digest(Buffer.from(JSON.stringify(result.names)));
return result;
}
export function assertPass(result, exitCode) {
demand(exitCode === 0 && result.passed > 0 && !result.failed && !result.skipped && !result.retries && !result.unexecuted, 'Native result cannot qualify as PASS');
}
export function requireProtectedEntry() {
return { eligible: false, reason: 'External protected phase10-development-entry, candidate scope, qualified runtime and test adapter are required. This evidence validator grants no authority.' };
}
function readSafe(relative, maximum = 64 * 1024 * 1024, base = directory) {
demand(typeof relative === 'string' && !path.isAbsolute(relative) && !relative.split(/[\\/]/).includes('..'), 'Unsafe artifact path');
const file = path.resolve(base, relative);
demand(fs.realpathSync(file).startsWith(fs.realpathSync(base) + path.sep), 'Artifact escapes package');
const stat = fs.statSync(file);
demand(stat.isFile() && stat.size <= maximum, 'Missing or excessive artifact');
return fs.readFileSync(file);
}
export function validatePackage(base = directory) {
const readPackage = relative => readSafe(relative, 8 * 1024 * 1024, base);
// The schema is trusted verifier source, never replaced by an imported package's schema.
const schema = JSON.parse(readSafe('HANDOFF_SCHEMA.json'));
verifyBytes(readPackage('HANDOFF_SCHEMA.json'), digest(readSafe('HANDOFF_SCHEMA.json')));
const ajv = new Ajv({ allErrors: true, jsonPointers: true });
const validate = ajv.compile(schema);
const documents = {};
for (const name of ['ACCEPTANCE', 'GATES', 'BLOCKERS', 'TEST_EVIDENCE']) {
const document = JSON.parse(readPackage(name + '.json'));
demand(validate(document), `Invalid ${name}: ${ajv.errorsText(validate.errors)}`);
documents[name] = document;
}
const evidence = documents.TEST_EVIDENCE;
demand(new Set(evidence.artifacts.map(item => item.id)).size === evidence.artifacts.length, 'Duplicate artifact IDs');
demand(new Set(evidence.reports.map(item => item.id)).size === evidence.reports.length, 'Duplicate report IDs');
for (const artifact of evidence.artifacts) verifyBytes(readPackage(artifact.path), artifact.sha256);
const artifact = name => {
const item = evidence.artifacts.find(entry => entry.id === name);
demand(item, 'Missing indexed artifact');
const bytes = readPackage(item.path);
return item.path.endsWith('.gz') ? zlib.gunzipSync(bytes, { maxOutputLength: 8 * 1024 * 1024 }) : bytes;
};
for (const report of evidence.reports) {
const expectedCommit = report.bindingScope === 'HANDOFF_VERIFIER' ? evidence.verifierCommit : evidence.testedCommit;
demand(report.sourceCommit === expectedCommit, 'Report source correspondence missing');
let nativeExit = JSON.parse(artifact(report.processArtifactId).toString('utf8'));
for (const raw of report.processPointer.slice(1).split('/')) {
const key = raw.replaceAll('~1', '/').replaceAll('~0', '~');
demand(nativeExit !== null && Object.hasOwn(nativeExit, key), 'Missing native process observation');
nativeExit = nativeExit[key];
}
demand(nativeExit === report.exitCode, 'Native process exit does not match the report');
if (!report.nativeFormat) continue;
const measured = measureNative(report.nativeFormat, artifact(report.artifactId).toString('utf8'));
for (const key of ['passed', 'failed', 'skipped', 'retries', 'unexecuted']) demand(measured[key] === report.counts[key], `Wrong ${report.id} ${key}`);
demand(measured.membershipSha256 === report.membershipSha256, 'Changed report membership');
if (report.status === 'PASS') assertPass(measured, report.exitCode);
}
const discovery = measureNative('playwright', artifact(evidence.ordinaryDiscoveryArtifactId).toString('utf8')).names;
const ordinary = evidence.ordinaryReportIds.flatMap(id => {
const report = evidence.reports.find(item => item.id === id);
demand(report?.nativeFormat === 'playwright' && report.status === 'PASS', 'Missing ordinary browser lane');
return measureNative('playwright', artifact(report.artifactId).toString('utf8')).names;
}).sort();
demand(new Set(ordinary).size === ordinary.length && JSON.stringify(ordinary) === JSON.stringify(discovery), 'Browser discovery differs from executed unique membership');
const source = JSON.parse(artifact(evidence.sourceManifestArtifactId).toString('utf8'));
const after = JSON.parse(artifact(evidence.sourceAfterArtifactId).toString('utf8'));
demand(JSON.stringify(source) === JSON.stringify(after), 'Source drifted during the regression run');
const prefixes = ['JobTrackerApi/', 'JobTrackerApi.Tests/', 'JobTrackerApi.MariaDb.Tests/', 'job-tracker-ui/', 'scripts/', 'tools/summarizer/', 'docs/pre-phase10-ux/', 'docs/redesign/phase-9/'];
const tracked = execFileSync('rtk', ['proxy', 'git', 'ls-files', '-z'], { cwd: root, encoding: 'utf8' }).split('\0').filter(name => prefixes.some(prefix => name.startsWith(prefix))).sort();
demand(Object.keys(source).length <= 5000 && JSON.stringify(Object.keys(source).sort()) === JSON.stringify(tracked), 'Frozen source membership does not match the tracked qualification scope');
execFileSync('rtk', ['proxy', 'git', 'diff', '--quiet', evidence.testedCommit, '--', ...prefixes], { cwd: root });
for (const [name, expected] of Object.entries(source)) {
demand(!path.isAbsolute(name) && !name.split(/[\\/]/).includes('..'), 'Unsafe source path');
const file = path.resolve(root, name);
demand(fs.realpathSync(file).startsWith(fs.realpathSync(root) + path.sep), 'Source escapes repository');
verifyBytes(fs.readFileSync(file), expected);
}
const verifierFiles = JSON.parse(artifact(evidence.verifierSourceArtifactId).toString('utf8'));
const expectedVerifierFiles = ['verify.mjs', 'verify.test.mjs', 'package.test.mjs', 'verify_git.py', 'HANDOFF_SCHEMA.json'].map(name => 'docs/automation/handoffs/pre-phase10/' + name).sort();
demand(JSON.stringify(Object.keys(verifierFiles).sort()) === JSON.stringify(expectedVerifierFiles), 'Missing verifier source membership');
for (const [name, expected] of Object.entries(verifierFiles)) {
verifyBytes(fs.readFileSync(path.join(root, name)), expected);
verifyBytes(execFileSync('rtk', ['proxy', 'git', 'show', `${evidence.verifierCommit}:${name}`], { cwd: root }), expected);
}
execFileSync('rtk', ['proxy', 'git', 'merge-base', '--is-ancestor', evidence.verifierCommit, 'HEAD'], { cwd: root });
execFileSync('rtk', ['proxy', 'git', 'merge-base', '--is-ancestor', evidence.testedCommit, 'HEAD'], { cwd: root });
const delta = execFileSync('rtk', ['proxy', 'git', 'diff', '--name-only', evidence.testedCommit, 'HEAD'], { cwd: root, encoding: 'utf8' }).trim().split(/\r?\n/).filter(Boolean);
demand(delta.every(name => name.startsWith('docs/automation/handoffs/pre-phase10/')), 'Application changed after tested baseline');
const declared = documents.ACCEPTANCE;
demand(declared.localCommit === evidence.testedCommit && declared.authority === 'EVIDENCE_ONLY' && declared.phase10DevelopmentReadiness === 'NO-GO' && declared.eligibleForAutomaticExecution === false, 'Unapproved entry claim');
const ids = new Set(documents.BLOCKERS.items.map(item => item.id));
demand(ids.size === documents.BLOCKERS.items.length, 'Duplicate blocker IDs');
demand(declared.unresolvedBlockerIds.every(id => ids.has(id)), 'Unresolved blocker is not registered');
const open = documents.BLOCKERS.items.filter(item => item.status !== 'CLOSED').map(item => item.id).sort();
demand(JSON.stringify([...declared.unresolvedBlockerIds].sort()) === JSON.stringify(open), 'Unresolved findings were omitted');
demand(declared.permissibleDeferrals.every(id => documents.BLOCKERS.items.some(item => item.id === id && item.classification === 'CONDITIONAL_DEVELOPMENT' && item.deferralAuthority)), 'A mandatory or unsupported condition was relabelled as deferrable');
const decisions = new Map(declared.verdicts.map(item => [item.criterion, item.status]));
demand(decisions.size === declared.verdicts.length, 'Duplicate acceptance criteria');
for (const [criterion, status] of [['CV AI Accuracy', 'BLOCKED'], ['Human/Device Acceptance', 'NOT TESTED'], ['Hosted CI', 'BLOCKED'], ['Production', 'NO-GO']])
demand(decisions.get(criterion) === status, 'Unqualified model/human/hosted/release acceptance was promoted');
const probe = JSON.parse(artifact('model-probe-results').toString('utf8'));
demand(probe.cases?.length === 3 && probe.cases.every(item => item.status === 'REJECTED_OR_FAILED'), 'This snapshot lacks its recorded negative CV-model evidence');
const oracles = evidence.reports.filter(item => ['windows-template-oracle', 'linux-template-oracle'].includes(item.id));
demand(oracles.length === 2 && oracles.every(item => item.status === 'PARTIAL' && item.exitCode !== 0), 'Original PDF failures were concealed');
demand(decisions.get('Automated PDF Fidelity') === 'FAIL' && decisions.get('PDF Spacing') === 'PARTIAL', 'Revised PDF failures were promoted');
const stages = documents.GATES.stages;
demand(stages.map(item => item.sequence).join(',') === '5,6,7,8,9,10,11' && stages[0].manualApprovals.includes('phase10-development-entry') && stages[0].manualApprovals.includes('candidate-specific-cleanup-scope') && stages.every(item => item.permissibleConditionalDeferrals.length === 0), 'Source-derived stage/approval conditions changed');
const observedLocalCommit = execFileSync('rtk', ['proxy', 'git', 'rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim();
const observedWorkingTreeClean = !execFileSync('rtk', ['proxy', 'git', 'status', '--porcelain'], { cwd: root, encoding: 'utf8' }).trim();
const branch = execFileSync('rtk', ['proxy', 'git', 'branch', '--show-current'], { cwd: root, encoding: 'utf8' }).trim();
demand(branch === declared.sourceBranch, 'Wrong checkout branch');
return { integrity: 'PASS', testedCommit: evidence.testedCommit, observedLocalCommit, observedWorkingTreeClean, reports: evidence.reports.length, artifacts: evidence.artifacts.length, phase10: 'BLOCKED', ...requireProtectedEntry() };
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
try {
const result = validatePackage();
if (process.argv.includes('--verify-remote')) {
const branch = 'refs/heads/test/pre-phase10-ux-acceptance';
const line = execFileSync('rtk', ['proxy', 'git', 'ls-remote', '--exit-code', 'origin', branch], { cwd: root, encoding: 'utf8', timeout: 45000 }).trim();
demand(line === `${result.observedLocalCommit}\t${branch}`, 'Remote HEAD differs from current local HEAD');
result.currentRemotePublication = 'VERIFIED';
} else result.currentRemotePublication = 'NOT_RECHECKED';
console.log(JSON.stringify(result, null, 2));
if (process.argv.includes('--require-ready')) process.exitCode = 3;
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}