mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-03 21:28:59 +00:00
The body of _process.yml is read from dev (@dev, #623), so the flags and formats it passes to scripts are dev's. After "Опубликовать документ ревью" the working copy of job integrate is the task branch, and a show/ship branch with a clean merge is not rebased before review: its scripts/ may lag dev by days. review-doc-guard.mjs silently ignores unknown flags (the anchor lost #726 route and #737 usage), and a stale merge-candidate.mjs merges the old way. Only two calls (#723 push refusal, #726 route) were taken from dev, each with its own extraction, and on ship/reuse the remaining ones ran dev's version anyway: the script version depended on the path. Now one step right after setup-node extracts `git archive origin/dev scripts .github/workflows/validate.yml` into $RUNNER_TEMP/dev-tools and every repo script of the job runs from there via TOOLS (review-result-gate, review-doc-guard, reviews-index, merge-candidate, process-track route, status-label). validate.yml is part of the snapshot because workflow-jobs.mjs reads it relative to itself; without it ci-proof answers `failed (#622)` and every code merge would return to S6. The working copy stays the material: git, the document and paths are judged there. PROCESS.md §10.4 gets the paragraph "Скрипты конвейера — из dev": the model_review exception, merges of pipeline changes judged by dev's version, and compatible edits of the Validate proof contract. Tests: test/process-integrate-tools.test.mjs is the job contract (no step calls scripts/ from the working copy, every call goes through the snapshot, one archive from origin/dev with validate.yml, and the step as is yields a directory where ci-proof resolves the job contract); publish-push-refusal runs the publish step and the #413 step on real bash with a task branch whose review-doc-guard.mjs exits 7 (red with the old call). Existing harnesses take the snapshot step before the publish and decide steps; the #706 mutant anchor follows the status-label call. Issue: #749 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
175 lines
10 KiB
JavaScript
175 lines
10 KiB
JavaScript
// #556: враждебные фикстуры на границу «работа модели → привилегированная
|
|
// публикация». Стадия модели — единственная недоверенная в конвейере; всё, что
|
|
// она передаёт дальше, это один artifact, и публикация обязана принимать его
|
|
// как ввод противника. В inline-shell эту границу нельзя было прогнать ни одним
|
|
// отрицательным случаем — потому проверка и вынесена в функцию.
|
|
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { readFileSync } from 'node:fs';
|
|
|
|
import {
|
|
reviewResultProblems, parseManifest, sha256, REQUIRED_FILES, PASSPORT_FIELDS, ROUTES, verdictRoute,
|
|
} from '../scripts/review-result-gate.mjs';
|
|
|
|
const EXPECTED = Object.fromEntries(PASSPORT_FIELDS.map((f) => [f, `значение-${f}`]));
|
|
|
|
/** Честный artifact стадии модели. */
|
|
function fixture(over = {}) {
|
|
const document = over.document ?? '# разбор\n\nвсё проверено\n';
|
|
const prepared = JSON.stringify({ schema: 1, ...EXPECTED, ...(over.prepared || {}) });
|
|
const verdict = JSON.stringify({ verdict: 'green', high: 0, medium: 0, summary: 'ok', ...(over.verdict || {}) });
|
|
const body = { 'review-document.md': document, 'prepared.json': prepared, 'verdict.json': verdict };
|
|
const manifest = Object.entries(body)
|
|
.map(([name, text]) => `${over.breakSum === name ? 'f'.repeat(64) : sha256(text)} ${name}`)
|
|
.join('\n') + '\n';
|
|
const files = { ...body, 'manifest.sha256': manifest, ...(over.extraFiles || {}) };
|
|
for (const name of over.drop || []) delete files[name];
|
|
return {
|
|
files: Object.keys(files),
|
|
read: (name) => files[name],
|
|
expected: EXPECTED,
|
|
};
|
|
}
|
|
|
|
test('#556: честный результат принимается', () => {
|
|
assert.deepEqual(reviewResultProblems(fixture()), []);
|
|
});
|
|
|
|
test('#556: неполный набор файлов отвергается', () => {
|
|
for (const name of REQUIRED_FILES) {
|
|
const problems = reviewResultProblems(fixture({ drop: [name] }));
|
|
assert.ok(problems.length >= 1, name);
|
|
assert.match(problems[0], /набор файлов не тот/, name);
|
|
}
|
|
});
|
|
|
|
test('#556: лишний файл в artifact отвергается', () => {
|
|
const problems = reviewResultProblems(fixture({ extraFiles: { 'payload.sh': 'rm -rf /' } }));
|
|
assert.match(problems[0], /набор файлов не тот/);
|
|
});
|
|
|
|
test('#556: подменённое содержимое ловится контрольной суммой', () => {
|
|
for (const name of ['review-document.md', 'prepared.json', 'verdict.json']) {
|
|
const problems = reviewResultProblems(fixture({ breakSum: name }));
|
|
assert.ok(problems.some((p) => p.includes(`контрольная сумма не сходится: ${name}`)), name);
|
|
}
|
|
});
|
|
|
|
test('#556: чужой прогон и чужая попытка отвергаются', () => {
|
|
for (const field of ['run_id', 'run_attempt']) {
|
|
const problems = reviewResultProblems(fixture({ prepared: { [field]: 'чужое' } }));
|
|
assert.ok(problems.some((p) => p.startsWith(`паспорт не совпал: ${field}`)), field);
|
|
}
|
|
});
|
|
|
|
test('#556: устаревший материал отвергается — и по SHA, и по дереву', () => {
|
|
for (const field of ['material_sha', 'material_tree']) {
|
|
const problems = reviewResultProblems(fixture({ prepared: { [field]: 'a'.repeat(40) } }));
|
|
assert.ok(problems.some((p) => p.startsWith(`паспорт не совпал: ${field}`)), field);
|
|
}
|
|
});
|
|
|
|
test('#556: чужая задача, этап, раунд и ветка отвергаются', () => {
|
|
for (const field of ['issue', 'stage', 'cycle', 'branch']) {
|
|
const problems = reviewResultProblems(fixture({ prepared: { [field]: 'чужое' } }));
|
|
assert.ok(problems.some((p) => p.startsWith(`паспорт не совпал: ${field}`)), field);
|
|
}
|
|
});
|
|
|
|
test('#556: сверяется КАЖДОЕ поле паспорта, а не выбранные', () => {
|
|
for (const field of PASSPORT_FIELDS) {
|
|
const problems = reviewResultProblems(fixture({ prepared: { [field]: 'подменено' } }));
|
|
assert.ok(problems.some((p) => p.startsWith(`паспорт не совпал: ${field}`)), field);
|
|
}
|
|
});
|
|
|
|
test('#556: чужая схема паспорта отвергается', () => {
|
|
assert.ok(reviewResultProblems(fixture({ prepared: { schema: 2 } }))
|
|
.some((p) => p.includes('не та схема')));
|
|
});
|
|
|
|
test('#556: вердикт вне словаря и кривые поля отвергаются', () => {
|
|
assert.ok(reviewResultProblems(fixture({ verdict: { verdict: 'merged' } }))
|
|
.some((p) => p.startsWith('verdict вне словаря')));
|
|
assert.ok(reviewResultProblems(fixture({ verdict: { high: '0' } }))
|
|
.some((p) => p.includes('high не число')));
|
|
assert.ok(reviewResultProblems(fixture({ verdict: { summary: 42 } }))
|
|
.some((p) => p.includes('summary не строка')));
|
|
});
|
|
|
|
test('#556: пустой документ ревью — не документ', () => {
|
|
assert.ok(reviewResultProblems(fixture({ document: ' \n' }))
|
|
.some((p) => p.includes('документ ревью пуст')));
|
|
});
|
|
|
|
test('#556: manifest, который покрывает не те файлы, отвергается', () => {
|
|
const base = fixture();
|
|
const read = (name) => (name === 'manifest.sha256'
|
|
? `${sha256(base.read('verdict.json'))} verdict.json\n`
|
|
: base.read(name));
|
|
assert.ok(reviewResultProblems({ ...base, read })
|
|
.some((p) => p.startsWith('manifest покрывает не те файлы')));
|
|
});
|
|
|
|
test('#556: неразбираемые manifest и JSON — отказ, а не исключение', () => {
|
|
const base = fixture();
|
|
const broken = (name, text) => ({ ...base, read: (n) => (n === name ? text : base.read(n)) });
|
|
assert.deepEqual(reviewResultProblems(broken('manifest.sha256', 'мусор')), ['manifest.sha256 не разобран']);
|
|
assert.ok(reviewResultProblems(broken('prepared.json', '{')).some((p) => p.includes('prepared.json не разобран')));
|
|
assert.ok(reviewResultProblems(broken('verdict.json', '{')).some((p) => p.includes('verdict.json не разобран')));
|
|
});
|
|
|
|
test('#556: разбор строки sha256sum терпит и пробел, и звёздочку', () => {
|
|
const hex = 'a'.repeat(64);
|
|
assert.deepEqual(parseManifest(`${hex} x.md\n`), [{ hash: hex, name: 'x.md' }]);
|
|
assert.deepEqual(parseManifest(`${hex} *x.md\n`), [{ hash: hex, name: 'x.md' }]);
|
|
assert.equal(parseManifest('не хеш вовсе'), null);
|
|
});
|
|
|
|
// Проверка обязана стоять на пути привилегированной стадии, а не просто
|
|
// существовать в репозитории.
|
|
test('#556: integrate пропускает artifact только через гейт', () => {
|
|
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
|
const integrate = workflow.slice(workflow.indexOf('\n integrate:\n'));
|
|
const step = integrate.slice(
|
|
integrate.indexOf(' - name: Проверить полноту и происхождение результата'),
|
|
integrate.indexOf(' # Ревьюер пишет только в docs/reviews/.'),
|
|
);
|
|
assert.ok(step.length > 0, 'шаг проверки найден');
|
|
assert.match(step, /^\s+node "\$TOOLS\/scripts\/review-result-gate\.mjs" --dir="\$dir"$/m);
|
|
for (const field of PASSPORT_FIELDS) {
|
|
if (field === 'run_id' || field === 'run_attempt') continue; // приходят из GITHUB_*
|
|
assert.match(step, new RegExp(`^\\s+${field.toUpperCase()}: `, 'm'), `${field} передаётся гейту`);
|
|
}
|
|
// Публикация читает вердикт только после гейта.
|
|
assert.ok(step.indexOf('review-result-gate.mjs') < step.indexOf('structured_output'));
|
|
});
|
|
|
|
// #726: маршрут вердикта на границе доверия. Смысл criterion судит reviewRoute,
|
|
// граница — только словарь route и противоречие с зелёным вердиктом.
|
|
test('#726 AC1: route — нет → fix, словарь, green + reclassify — отказ, criterion любой строкой', () => {
|
|
assert.deepEqual(ROUTES, ['fix', 'reclassify']);
|
|
// route нет (вердикты до #726, модель не заполнила) — принято и читается как fix.
|
|
assert.deepEqual(reviewResultProblems(fixture()), []);
|
|
assert.deepEqual(reviewResultProblems(fixture({ verdict: { verdict: 'yellow', route: null } })), [], 'null — не заполнено');
|
|
assert.equal(verdictRoute({ verdict: 'yellow' }), 'fix');
|
|
assert.equal(verdictRoute({ verdict: 'yellow', route: null }), 'fix');
|
|
for (const [verdict, route] of [['yellow', 'fix'], ['red', 'fix'], ['green', 'fix'], ['yellow', 'reclassify'], ['red', 'reclassify']]) {
|
|
assert.deepEqual(reviewResultProblems(fixture({ verdict: { verdict, route } })), [], `${verdict} + ${route}`);
|
|
assert.equal(verdictRoute({ verdict, route }), route);
|
|
}
|
|
// Вне словаря — отказ.
|
|
for (const route of ['merge', 'Fix', '', 42, true, {}, ['fix']]) {
|
|
const problems = reviewResultProblems(fixture({ verdict: { verdict: 'yellow', route } }));
|
|
assert.ok(problems.some((p) => p.startsWith('route вне словаря')), JSON.stringify(route));
|
|
assert.equal(verdictRoute({ route }), null);
|
|
}
|
|
// Зелёный вместе с reclassify — противоречивый результат, fail-closed.
|
|
const green = reviewResultProblems(fixture({ verdict: { verdict: 'green', route: 'reclassify', criterion: 'undocumented' } }));
|
|
assert.ok(green.some((p) => p.startsWith('reclassify при зелёном вердикте')), green.join('; '));
|
|
// criterion любой строкой проходит границу — его смысл судит маршрут (К2).
|
|
for (const criterion of ['undocumented', 'vibes', '', 'q` --> <b>']) {
|
|
assert.deepEqual(reviewResultProblems(fixture({ verdict: { verdict: 'yellow', route: 'reclassify', criterion } })), [], criterion);
|
|
}
|
|
});
|