83 lines
4.5 KiB
JavaScript
83 lines
4.5 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* produce-verify-receipt (G1) — производитель подписанной verify-расписки.
|
||
* Чистое ядро buildVerifyReceipt: настоящий green ⇔ suitePassed===true И есть ключ.
|
||
* I/O-обёртка main сама прогоняет сюиту (флаг не от контроллера, SE-G1-5), считает
|
||
* fingerprint staged-diff, инкрементит occurrence, персистит в protected runtime.
|
||
*
|
||
* Файл расписки — ЕДИНЫЙ `~/.claude/runtime/verify-receipt.json` (НЕ session-scoped):
|
||
* producer (CLI) не получает session_id из stdin как consumer-хук, поэтому единый путь
|
||
* исключает рассинхрон. Безопасно для Level A — свежесть держит fingerprint (старая
|
||
* расписка ≠ текущему staged-diff → stale-fingerprint → block у consumer).
|
||
*/
|
||
import { signVerifyReceipt } from './verify-receipt.mjs';
|
||
import { codeFingerprint } from './criterion-green.mjs';
|
||
import { execFileSync, execSync } from 'node:child_process';
|
||
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
import { homedir } from 'node:os';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
export function buildVerifyReceipt({ codeFingerprint: fp, lastOccurrence = 0, suitePassed = false, signerKey } = {}) {
|
||
if (suitePassed !== true) return { ok: false, reason: 'suite-not-passed' };
|
||
const occurrence = lastOccurrence + 1;
|
||
const receipt = signVerifyReceipt({ code_fingerprint: fp, occurrence }, signerKey);
|
||
if (!receipt) return { ok: false, reason: 'no-signer-key' };
|
||
return { ok: true, receipt };
|
||
}
|
||
|
||
/** Отпечаток staged-diff: sha256 канонизированной карты {путь: содержимое} (Δ2 через codeFingerprint). */
|
||
export function stagedFingerprint(fileContents = {}) {
|
||
return codeFingerprint(fileContents);
|
||
}
|
||
|
||
const RECEIPT_FILE = 'verify-receipt.json';
|
||
|
||
function gitTopLevel() {
|
||
return execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf-8' }).trim();
|
||
}
|
||
function stagedFiles(gitCwd) {
|
||
return execFileSync('git', ['-C', gitCwd, 'diff', '--staged', '--name-only'], { encoding: 'utf-8' })
|
||
.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
||
}
|
||
function stagedContents(gitCwd, files) {
|
||
const map = {};
|
||
for (const f of files) { try { map[f] = readFileSync(join(gitCwd, f), 'utf-8'); } catch { /* deleted/binary — skip */ } }
|
||
return map;
|
||
}
|
||
|
||
/** I/O-main: staged → fingerprint → прогон сюиты → buildVerifyReceipt → персист в protected runtime. */
|
||
async function main() {
|
||
const dir = join(homedir(), '.claude', 'runtime');
|
||
const path = join(dir, RECEIPT_FILE);
|
||
const gitCwd = gitTopLevel();
|
||
const fp = stagedFingerprint(stagedContents(gitCwd, stagedFiles(gitCwd)));
|
||
// Producer САМ прогоняет сюиту (SE-G1-5: флаг не от контроллера). Exit 0 = passed.
|
||
let suitePassed = false;
|
||
try {
|
||
// Windows-safe (SE-G1-5 сохранён: флаг не от контроллера): npx — это npx.cmd, и
|
||
// execFileSync('npx') без shell его НЕ находит → бросает → suite-not-passed навсегда.
|
||
// execSync со строкой + кавычки на путях (в пути репо есть пробелы) запускает реально.
|
||
const appRoot = join(gitCwd, 'app');
|
||
const cfg = join(gitCwd, 'app', 'vitest.config.tools.mjs');
|
||
execSync(`npx vitest run --root "${appRoot}" --config "${cfg}" --reporter dot`,
|
||
{ cwd: gitCwd, stdio: 'ignore' });
|
||
suitePassed = true;
|
||
} catch { suitePassed = false; }
|
||
const { resolveReceiptKey } = await import('./receipt-key-config.mjs');
|
||
let last = 0;
|
||
if (existsSync(path)) { try { last = JSON.parse(readFileSync(path, 'utf-8')).occurrence || 0; } catch { last = 0; } }
|
||
const r = buildVerifyReceipt({ codeFingerprint: fp, lastOccurrence: last, suitePassed, signerKey: resolveReceiptKey() });
|
||
try { mkdirSync(dir, { recursive: true }); } catch {}
|
||
if (r.ok) {
|
||
writeFileSync(path, JSON.stringify(r.receipt));
|
||
process.stdout.write(`[produce-verify-receipt] signed GREEN: fp=${fp.slice(0, 12)} occ=${r.receipt.occurrence}\n`);
|
||
} else {
|
||
process.stdout.write(`[produce-verify-receipt] NOT signed: ${r.reason}\n`);
|
||
}
|
||
process.exit(0);
|
||
}
|
||
|
||
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
||
if (isCli) main();
|