fix: exempt pipeline review documents from the branch-name gate rule (#305)

Правило 2 локального process-gate блокировало пуш issue-ветки после того, как
конвейер «Привести ветку к dev» вносил в неё свежую историю dev с чужим
коммитом «docs: review document for #NNN» — локальный origin/dev автора
отставал и не вычитал его из диапазона, а нарушение в истории не чинится
следующим коммитом (единственный выход был --no-verify по §12/17).

Исключение доказуемое и fail-closed: точный subject документа ревью И дифф
только в docs/reviews/ (files обязателен — ownCommits теперь читаются с
filesOf). Любой код рядом с документом или пустой список файлов возвращают
правило 2 в строй; юниты фиксируют оба контура.

Issue: #305
User-Visible: no
This commit is contained in:
Codex
2026-08-26 00:24:22 +03:00
parent b1b8fb9fab
commit 30698d6ec4
2 changed files with 49 additions and 0 deletions
+17
View File
@@ -202,12 +202,26 @@ export function evaluateCommit(c) {
// уезжает назад, и в диапазон попадают коммиты `dev` с чужими номерами issue —
// каждый из них выглядел бы нарушением. Проверено на реальной истории: сидя на
// issue/89 с диапазоном по dev, гейт дал 26 ложных отказов из 26 коммитов.
// Документ ревью, доказанно созданный конвейером (#305): точный subject
// «docs: review document for #NN» и дифф ТОЛЬКО в docs/reviews/. Такой коммит
// попадает в чужую issue-ветку легитимно — шаг «Привести ветку к dev»
// вносит свежую историю dev, где конвейер только что опубликовал документ
// соседней задачи, а локальный origin/dev автора мог отстать и не вычесть его
// из диапазона. Кода в таком коммите нет по построению (files-критерий), и
// судить им правило 2 — ложный отказ, чинимый только --no-verify.
export function isPipelineReviewDocCommit(c) {
return /^docs: review document for #\d+$/.test(c.subject ?? '')
&& (c.files?.length ?? 0) > 0
&& c.files.every((f) => f.startsWith('docs/reviews/'));
}
export function checkBranchRule(branch, commits) {
const m = (branch ?? '').match(/^issue\/(\d+)-/);
if (!m) return [];
const want = `#${m[1]}`;
const out = [];
for (const c of commits) {
if (isPipelineReviewDocCommit(c)) continue;
for (const t of c.issues) {
if (t !== want) {
out.push({
@@ -541,8 +555,11 @@ function main(argv) {
const hasDev = spawnSync('git', ['-C', repo, 'rev-parse', '--verify', 'origin/dev'],
{ encoding: 'utf8' }).status === 0;
if (!hasDev) return checkedCommits;
// filesOf обязателен: исключение #305 доказывается диффом коммита
// (только docs/reviews/), а без файлов оно не срабатывает fail-closed.
const own = parseRecords(
git(['log', '--reverse', `--pretty=format:${LOG_FORMAT}`, 'origin/dev..HEAD'], repo),
filesOf,
);
return commitsNeedingTargetValidation(own, { targetRef, isCommitOnMain });
})()
+32
View File
@@ -11,6 +11,7 @@ import {
STRICT_STATUS,
buildReport,
checkBranchRule,
isPipelineReviewDocCommit,
checkIssueStatuses,
checkReviewDocLimit,
REVIEW_DOC_LIMIT,
@@ -648,3 +649,34 @@ test('the CLI waives the issue status for a class-B-only range but not with clas
rmSync(dir, { recursive: true, force: true });
}
});
test('rule 2 exempts a pipeline review document proven by subject and diff (#305)', () => {
const doc = makeCommit({
sha: 'a'.repeat(40),
subject: 'docs: review document for #304',
body: 'Issue: #304\nUser-Visible: no',
files: ['docs/reviews/CODE-REVIEW-304-r1.md'],
});
assert.equal(isPipelineReviewDocCommit(doc), true);
assert.deepEqual(checkBranchRule('issue/302-junction-node-material', [doc]), []);
// Any code beside the document voids the proof — rule 2 fires again.
const forged = makeCommit({
sha: 'b'.repeat(40),
subject: 'docs: review document for #304',
body: 'Issue: #304',
files: ['docs/reviews/CODE-REVIEW-304-r1.md', 'src/houseplan-card.ts'],
});
assert.equal(isPipelineReviewDocCommit(forged), false);
assert.equal(checkBranchRule('issue/302-junction-node-material', [forged]).length, 1);
// Fail-closed: without a file list the exemption cannot prove itself.
const blind = makeCommit({
sha: 'c'.repeat(40),
subject: 'docs: review document for #304',
body: 'Issue: #304',
files: [],
});
assert.equal(isPipelineReviewDocCommit(blind), false);
assert.equal(checkBranchRule('issue/302-junction-node-material', [blind]).length, 1);
});