mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
A non-green show verdict that found "something to decide" went down the same path as "fix the code": S6 with a limit of 2. Promoting the task to track:ask was left to the agent's memory, with no named criterion and no trace, and the exhausted budget only surfaced on the next S7 - after a fix nobody would read. The structured verdict now carries `route` (fix | reclassify) and an optional `criterion` (one of the six show criteria of PROCESS.md section 5). The trust boundary reads a missing route as fix, rejects one outside the dictionary and rejects reclassify on a green verdict. `reviewRoute` in process-track.mjs is the single decision: on a code review of an unconfirmed show it moves the task to track:ask and S3-spec; on an owner-confirmed show it adds `blocked` and asks the owner; anywhere else reclassify degrades to fix with a note. The verdict that spends the last cycle sets review-4 at once; the stage budget is shared across tracks, so promotion changes the limit (4), not the count. The "Решение по вердикту" step makes one `process-track.mjs route` call (from dev, like the track step) and only executes its output: comment from a file, labels from add/remove lists, status via status-label.mjs as before. The track step also emits `confirmed` and a `route_note` for the review prompt; the review document anchor gains a route tail that the old reader still parses; wait-verdict reports the two new pipeline comments. The guard's own spent >= limit check stays as the safety net. Issue: #726 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
156 lines
8.0 KiB
JavaScript
156 lines
8.0 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* Граница доверия между стадией модели и привилегированной публикацией (#556).
|
||
*
|
||
* `model_review` — единственная недоверенная стадия конвейера: там исполняется
|
||
* чужой код с Read/Write/Bash. Всё, что она может передать дальше, — один
|
||
* artifact. Публикация разбора, перестановка метки и слияние в `dev` идут в
|
||
* другой job, на чистом checkout `dev`, и обязаны принимать этот artifact как
|
||
* недоверенный ввод: полный набор файлов, сходящиеся контрольные суммы и
|
||
* совпадение КАЖДОГО поля паспорта с тем, что посчитала детерминированная
|
||
* стадия `prepare`. Подменённый, неполный, устаревший или чужой результат
|
||
* отвергается fail-closed.
|
||
*
|
||
* Проверка вынесена из inline-shell в функцию именно ради враждебных фикстур:
|
||
* в YAML её нельзя прогнать ни одним отрицательным случаем.
|
||
*
|
||
* node scripts/review-result-gate.mjs --dir=<путь> # поля ожидания из env
|
||
*/
|
||
import { createHash } from 'node:crypto';
|
||
import { readFileSync, readdirSync } from 'node:fs';
|
||
import { resolve } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
/** Ровно эти файлы и ни одного больше: лишний файл — это уже чужой artifact. */
|
||
export const REQUIRED_FILES = ['manifest.sha256', 'prepared.json', 'review-document.md', 'verdict.json'];
|
||
/** Паспорт материала. Каждое поле сверяется с outputs стадии `prepare`. */
|
||
export const PASSPORT_FIELDS = [
|
||
'run_id', 'run_attempt', 'issue', 'stage', 'cycle', 'branch',
|
||
'material_sha', 'material_tree', 'material_specs', 'material_issue_body',
|
||
'validate_result', 'validate_url', 'rebase_note', 'validated_note',
|
||
'spec_body_changed', 'spec_body_doc', 'spec_body_recorded',
|
||
];
|
||
export const VERDICTS = ['green', 'yellow', 'red'];
|
||
/**
|
||
* Маршрут вердикта (#726): `fix` — вернуть автору по находкам; `reclassify` —
|
||
* трек `show` выбран неверно, задача не проходит критерий §5. Смысл
|
||
* `criterion` судит `reviewRoute` (`process-track.mjs`), а не граница.
|
||
*/
|
||
export const ROUTES = ['fix', 'reclassify'];
|
||
|
||
export const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
||
|
||
/**
|
||
* Маршрут структурного вердикта: поля нет (вердикты до #726, модель его не
|
||
* заполнила) — `fix`, прежний путь; значение вне словаря — `null`.
|
||
*/
|
||
export function verdictRoute(verdict) {
|
||
if (verdict?.route == null) return 'fix';
|
||
return ROUTES.includes(verdict.route) ? verdict.route : null;
|
||
}
|
||
|
||
/** Причины отказа самого `verdict.json`; пустой массив — вердикт цел. */
|
||
export function verdictProblems(verdict) {
|
||
const problems = [];
|
||
if (!VERDICTS.includes(verdict?.verdict)) problems.push(`verdict вне словаря: ${JSON.stringify(verdict?.verdict)}`);
|
||
if (typeof verdict?.high !== 'number') problems.push('verdict.high не число');
|
||
if (typeof verdict?.medium !== 'number') problems.push('verdict.medium не число');
|
||
if (typeof verdict?.summary !== 'string') problems.push('verdict.summary не строка');
|
||
const route = verdictRoute(verdict);
|
||
if (route === null) {
|
||
problems.push(`route вне словаря: ${JSON.stringify(verdict?.route)}`);
|
||
} else if (route === 'reclassify' && verdict?.verdict === 'green') {
|
||
// Противоречивый результат: «вперёд» и «трек неверен» сразу. Fail-closed,
|
||
// молчаливый green здесь хуже остановки (#726, порядок владельца #707).
|
||
problems.push('reclassify при зелёном вердикте — результат противоречив');
|
||
}
|
||
return problems;
|
||
}
|
||
|
||
/** Разбор строки `sha256sum`: «<hex> <имя>». */
|
||
export function parseManifest(text) {
|
||
const rows = [];
|
||
for (const line of String(text).split('\n')) {
|
||
if (!line.trim()) continue;
|
||
const match = /^([0-9a-f]{64})\s[\s*](.+)$/.exec(line);
|
||
if (!match) return null;
|
||
rows.push({ hash: match[1], name: match[2].trim() });
|
||
}
|
||
return rows;
|
||
}
|
||
|
||
/**
|
||
* @param {object} p
|
||
* @param {string[]} p.files имена файлов в каталоге artifact
|
||
* @param {(name:string)=>string} p.read содержимое файла
|
||
* @param {Record<string,string>} p.expected паспорт, посчитанный `prepare`
|
||
* @returns {string[]} причины отказа; пустой массив — принять
|
||
*/
|
||
export function reviewResultProblems({ files, read, expected }) {
|
||
const problems = [];
|
||
const actual = [...files].sort();
|
||
const wanted = [...REQUIRED_FILES].sort();
|
||
if (actual.join(',') !== wanted.join(',')) {
|
||
return [`набор файлов не тот: ожидалось ${wanted.join(', ')}, получено ${actual.join(', ') || '(пусто)'}`];
|
||
}
|
||
|
||
const manifest = parseManifest(read('manifest.sha256'));
|
||
if (!manifest) return ['manifest.sha256 не разобран'];
|
||
const covered = manifest.map((row) => row.name).sort();
|
||
const mustCover = REQUIRED_FILES.filter((name) => name !== 'manifest.sha256').sort();
|
||
if (covered.join(',') !== mustCover.join(',')) {
|
||
problems.push(`manifest покрывает не те файлы: ${covered.join(', ') || '(пусто)'}`);
|
||
}
|
||
for (const row of manifest) {
|
||
if (!REQUIRED_FILES.includes(row.name)) continue;
|
||
const digest = sha256(read(row.name));
|
||
if (digest !== row.hash) problems.push(`контрольная сумма не сходится: ${row.name}`);
|
||
}
|
||
|
||
if (!String(read('review-document.md')).trim()) problems.push('документ ревью пуст');
|
||
|
||
let prepared;
|
||
try {
|
||
prepared = JSON.parse(read('prepared.json'));
|
||
} catch (error) {
|
||
return [...problems, `prepared.json не разобран: ${error.message}`];
|
||
}
|
||
if (prepared?.schema !== 1) problems.push('prepared.json: не та схема');
|
||
for (const field of PASSPORT_FIELDS) {
|
||
const want = expected[field] ?? '';
|
||
const got = prepared?.[field] ?? '';
|
||
if (String(got) !== String(want)) {
|
||
problems.push(`паспорт не совпал: ${field} = «${got}», ожидалось «${want}»`);
|
||
}
|
||
}
|
||
|
||
let verdict;
|
||
try {
|
||
verdict = JSON.parse(read('verdict.json'));
|
||
} catch (error) {
|
||
return [...problems, `verdict.json не разобран: ${error.message}`];
|
||
}
|
||
return [...problems, ...verdictProblems(verdict)];
|
||
}
|
||
|
||
const invokedDirectly = process.argv[1]
|
||
&& resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url));
|
||
if (invokedDirectly) {
|
||
const dir = process.argv.find((a) => a.startsWith('--dir='))?.slice(6);
|
||
if (!dir) { console.error('usage: review-result-gate.mjs --dir=<путь>'); process.exit(2); }
|
||
const env = (name) => process.env[name.toUpperCase()] ?? '';
|
||
const expected = Object.fromEntries(PASSPORT_FIELDS.map((field) => [field, env(field)]));
|
||
expected.run_id = process.env.GITHUB_RUN_ID ?? '';
|
||
expected.run_attempt = process.env.GITHUB_RUN_ATTEMPT ?? '';
|
||
const problems = reviewResultProblems({
|
||
files: readdirSync(dir, { withFileTypes: true }).filter((e) => e.isFile()).map((e) => e.name),
|
||
read: (name) => readFileSync(resolve(dir, name), 'utf8'),
|
||
expected,
|
||
});
|
||
if (problems.length) {
|
||
for (const problem of problems) console.error(`::error::результат модели отвергнут — ${problem}`);
|
||
process.exit(1);
|
||
}
|
||
console.log('результат модели полон, суммы сходятся, паспорт совпал');
|
||
}
|