import process from 'node:process'; import { shouldRunAdmissionCheck } from './ci-prepush-check-registry.mjs'; import { classifyChangedFiles, manualAdmissionNeedsFullValidation, requiresTypecheck, } from './ci-prepush-classifier.mjs'; import { defaultResolveUpstream, isMainModule, resolveManualEvidence, } from './ci-prepush-range-resolver.mjs'; import { gitFreeTestEnv, LOCAL_CHECK_TIMEOUTS_MS, RELATED_TEST_LIMIT, selectRelatedTests, } from './ci-prepush-related-tests.mjs'; import { ensureDependencyState, runLocalBinaryDetailed, runNodeScript } from './hooks/shared.mjs'; import { PR_BUDGET_EXIT_CODES } from './pr-budget.mjs'; function report(name, status, detail = '') { console.log(`[local-admission] ${name.padEnd(26)} ${status}${detail ? ` — ${detail}` : ''}`); return status; } // QNBS-v3: a bounded child that dies silently must still say how it ended (exit status, signal, timeout) so a hook FAIL is diagnosable. async function runDetailedCheck(name, run) { const result = await run(); const failed = result.error || result.timedOut || result.interrupted || (result.status ?? 1) !== 0; if (!failed) { report(name, 'PASS'); return; } const facts = [ `exit ${result.status ?? 'none'}`, `signal ${result.signal ?? 'none'}`, result.timedOut ? 'timed out' : null, result.interrupted ? 'interrupted' : null, result.error ? String(result.error.message ?? result.error) : null, ].filter(Boolean); report(name, 'FAIL', facts.join(', ')); process.exit(result.status || 1); } async function runCheck(name, run) { const status = await run(); report(name, status === 0 ? 'PASS' : 'FAIL'); if (status !== 0) process.exit(status ?? 1); } async function runReviewerConfigAdmission(classification, full) { if (shouldRunAdmissionCheck('reviewerConfig', classification.files) || full) await runCheck('Reviewer config', () => runNodeScript('scripts/check-reviewer-config.mjs')); } // QNBS-v3: tests that mention a changed file ran green locally; anything not run is named, never skipped silently. async function runRelatedTestsAdmission(classification) { const name = 'Related unit tests'; if (classification.kind === 'AMBIGUOUS' || classification.files.length === 0) { report(name, 'DEFERRED_TO_REQUIRED_CI', 'no resolved change evidence to select tests from'); return; } if (!shouldRunAdmissionCheck('relatedTests', classification.files)) { report(name, 'NONE', 'no governed source, workflow or script changed'); return; } const selection = selectRelatedTests(classification.files); if (selection.status === 'NONE') { report(name, 'NONE', 'no unit test mentions the changed files'); } else if (selection.status === 'DEFERRED') { report( name, 'DEFERRED_TO_REQUIRED_CI', `${selection.total} related test files exceed the local limit of ${RELATED_TEST_LIMIT}; run the ones you need with pnpm exec vitest run `, ); } else { console.log(`[local-admission] related test files: ${selection.tests.join(', ')}`); await runDetailedCheck(name, () => runLocalBinaryDetailed('vitest', ['run', ...selection.tests], { env: gitFreeTestEnv(), timeoutMs: LOCAL_CHECK_TIMEOUTS_MS.relatedTests, }), ); } } async function main() { const evidenceIndex = process.argv.indexOf('--prepush-evidence-file'); const evidenceFile = evidenceIndex >= 0 ? process.argv[evidenceIndex + 1] : undefined; const fullRequested = process.argv.includes('--full'); let manualEvidence; try { manualEvidence = resolveManualEvidence(evidenceFile); } catch (error) { console.error(`[local-lowend] outgoing evidence rejected: ${error.message}`); process.exit(1); } const full = fullRequested || manualAdmissionNeedsFullValidation(manualEvidence.rangeResolved); const classification = manualEvidence.rangeResolved ? classifyChangedFiles(manualEvidence.files) : { kind: 'AMBIGUOUS', categories: ['UNKNOWN'], files: [] }; const typecheckRequired = requiresTypecheck(classification, { full }); console.log(`[local-admission] change class: ${classification.kind}`); console.log(`[local-admission] files considered: ${classification.files.length}`); if (!manualEvidence.rangeResolved) console.log( '[local-admission] change evidence incomplete or unresolved; using conservative full admission', ); // QNBS-v3: informational only — required CI remains authoritative regardless of this signal. if (manualEvidence.workingTreeState === 'DIVERGED') report( 'Working tree vs. push', 'DIVERGED', 'local results do not verify the exact pushed commit(s); required CI remains authoritative', ); else if (manualEvidence.workingTreeState === 'UNKNOWN') report( 'Working tree vs. push', 'UNKNOWN', 'could not determine whether local results reflect the exact pushed commit(s); required CI remains authoritative', ); // QNBS-v3: informational only — compares the pushed commit's manifests to the local reconciled baseline. if (manualEvidence.dependencyState === 'DIVERGED') report( 'Dependency manifests vs. push', 'DIVERGED', 'pushed commit(s) declare dependencies not yet reconciled locally; required CI remains authoritative', ); else if (manualEvidence.dependencyState === 'UNKNOWN') report( 'Dependency manifests vs. push', 'UNKNOWN', 'could not compare pushed dependency manifests to the local baseline; required CI remains authoritative', ); if (!ensureDependencyState()) { report('Dependency state', 'FAIL'); process.exit(1); } report('Dependency state', 'PASS'); await runReviewerConfigAdmission(classification, full); const budgetStatus = await runNodeScript('scripts/pr-budget.mjs', ['--prepush']); if (budgetStatus === PR_BUDGET_EXIT_CODES.OK) report('PR budget', 'PASS'); else if (budgetStatus === PR_BUDGET_EXIT_CODES.UNRESOLVED_BASE) { report('PR budget', 'BLOCKED', 'run pnpm run pr:budget -- --base before pushing'); process.exit(PR_BUDGET_EXIT_CODES.UNRESOLVED_BASE); } else { report('PR budget', 'FAIL'); process.exit(budgetStatus); } await runCheck('Toolchain', () => runNodeScript('scripts/check-pnpm-toolchain.mjs', ['--hook'])); await runCheck('Docs/release truth', () => runNodeScript('scripts/check-doc-metrics.mjs')); await runCheck('R-15 gate status', () => runNodeScript('scripts/check-r15-gate-status.mjs')); await runCheck('CSP policy', () => runNodeScript('scripts/check-csp-policy.mjs')); await runCheck('Desktop import boundary', () => runNodeScript('scripts/check-tauri-import-boundary.mjs'), ); await runCheck('Native readiness', () => runNodeScript('scripts/check-native-readiness.mjs')); await runCheck('Tauri plugin version parity', () => runNodeScript('scripts/check-tauri-plugin-versions.mjs'), ); if (shouldRunAdmissionCheck('i18n', classification.files) || full) { await runCheck('i18n key parity', () => runNodeScript('scripts/check-i18n-keys.mjs')); await runCheck('i18n bundle rebuild', () => runNodeScript('scripts/build-i18n.mjs')); await runCheck('i18n translation quality', () => runNodeScript('scripts/i18n-quality-report.mjs', [ '--strict', '--min-coverage', '75', '--max-length-outliers', '8', ]), ); } if (shouldRunAdmissionCheck('contentGuard', classification.files) || full) await runCheck('Content guard', () => runNodeScript('scripts/content-guard.mjs')); if (shouldRunAdmissionCheck('workflowPolicy', classification.files) || full) await runCheck('Workflow policy', () => runNodeScript('scripts/workflow-policy-check.mjs')); if (shouldRunAdmissionCheck('qnbsCommentPolicy', classification.files) || full) { // QNBS-v3: when an upstream exists, resolve it once here instead of letting the checker redo it. const qnbsRef = defaultResolveUpstream(); await runCheck('QNBS-v3 comment policy', () => runNodeScript( 'scripts/check-qnbs-v3-comments.mjs', qnbsRef ? ['--range', qnbsRef] : ['--range'], ), ); } await runRelatedTestsAdmission(classification); if (typecheckRequired) { await runDetailedCheck('TypeScript (single checker)', () => // QNBS-v3: one checker bounds memory use on constrained developer machines. runLocalBinaryDetailed( 'tsgo', ['--project', 'tsconfig.tsgo.json', '--noEmit', '--checkers', '1'], { timeoutMs: LOCAL_CHECK_TIMEOUTS_MS.typecheck }, ), ); } else { report('TypeScript', 'DEFERRED_TO_REQUIRED_CI', 'no TypeScript-impacting changes detected'); } console.log('\nLOCAL ADMISSION RESULT'); console.log('Local checks completed sequentially.'); console.log('Cloud validation required YES'); console.log(`Classification ${classification.kind}`); } // QNBS-v3: guard execution so this module can be imported for testing without running the CLI. if (isMainModule(process.argv[1], import.meta.url)) await main();