From 7b58f73e575922b0eb21df52253c5d9d16d02a67 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 13 Sep 2026 09:44:42 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B4=D0=BE=D0=BA=D0=B0=D0=B7=D1=8B?= =?UTF-8?q?=D0=B2=D0=B0=D1=82=D1=8C=20infra-=D1=82=D1=80=D0=B5=D0=BA=20?= =?UTF-8?q?=D0=BF=D0=BE=20diff=20(#562)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #562 User-Visible: no --- scripts/task-packet.mjs | 16 +++++++++++----- test/task-packet.test.mjs | 17 +++++++++++++++-- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/scripts/task-packet.mjs b/scripts/task-packet.mjs index 5c138494..1a2de1d4 100644 --- a/scripts/task-packet.mjs +++ b/scripts/task-packet.mjs @@ -19,14 +19,14 @@ import { spawnSync } from 'node:child_process'; import { isMainModule } from './spawn-portable.mjs'; import { anchorTreeFrom, anchorVerdictFrom, verdictDeclaration } from './review-doc-guard.mjs'; +import { classify } from './process-gate.mjs'; export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged']; /** Что разрешено в статусе — по PROCESS.md, без домыслов. */ -export function rightsFor(status, labels = []) { +export function rightsFor(status, labels = [], { infrastructure = false } = {}) { const blocked = labels.includes('blocked'); const exhausted = labels.includes('review-4'); - const infrastructure = labels.includes('infra'); const code = !infrastructure && ['S5-ready', 'S6-in-progress', 'S7-code-review'].includes(status); const lines = []; if (exhausted) lines.push('review-4: лимит циклов исчерпан — решение владельца (разделить, отклонить, арбитраж); дальше не двигать'); @@ -112,7 +112,11 @@ export function buildPacket(inputs) { issue, labels = [], comments = [], owner = 'Matysh', branch = null, specs = [], reviewDocs = [], validate = null, } = inputs; const status = STATUS_LABELS.find((l) => labels.includes(l)) || null; - const track = labels.includes('infra') + // `infra` — тематическая метка и не даёт процессных прав. Ускоренный трек + // доказывается тем же механическим признаком, что process-gate: в реальном + // diff опубликованной ветки нет ни одного файла класса A. + const infrastructure = branch?.infrastructure === true; + const track = infrastructure ? 'инфраструктурный' : labels.includes('trivial') ? 'trivial' : labels.includes('small') ? 'small' : 'полный'; const stage = status === 'S4-spec-review' || status === 'S3-spec' || status === 'S5-ready' ? 'spec' : 'code'; @@ -128,7 +132,7 @@ export function buildPacket(inputs) { const unverified = acs.filter((a) => a.evidence.startsWith('без записи')); const packet = { issue: { number: issue.number, title: issue.title, state: issue.state, url: issue.url }, - status, track, labels, rights: rightsFor(status, labels), + status, track, labels, rights: rightsFor(status, labels, { infrastructure }), decisions: ownerDecisions(comments, owner), material: branch ? { branch: branch.name, tip: branch.tip, base: branch.base, ahead: branch.ahead, behind: branch.behind, @@ -202,6 +206,8 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr const base = sh('git', ['merge-base', 'origin/dev', ref], { cwd }); const ahead = Number(sh('git', ['rev-list', '--count', `origin/dev..${ref}`], { cwd })); const behind = Number(sh('git', ['rev-list', '--count', `${ref}..origin/dev`], { cwd })); + const changedFiles = sh('git', ['diff', '--name-only', `${base}..${ref}`], { cwd }).split('\n').filter(Boolean); + const infrastructure = changedFiles.length > 0 && !changedFiles.some((name) => classify(name) === 'A'); // Дерево без docs/reviews — для сравнения с якорем вердикта: git сам его не даёт, // поэтому сравнение делается diff'ом при известном якоре (см. ниже). const names = sh('git', ['ls-tree', '--name-only', `${ref}:docs/reviews`], { cwd }).split('\n').filter((n) => new RegExp(`-${number}-r\\d+\\.md$`).test(n)); @@ -214,7 +220,7 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr const same = spawnSync('git', ['diff', '--quiet', anchorTree, tip, '--', '.', ':!docs/reviews'], { cwd }); treeWithoutReviews = same.status === 0 ? anchorTree : `differs-from-${anchorTree}`; } - branch = { name, tip, base, ahead, behind, treeWithoutReviews }; + branch = { name, tip, base, ahead, behind, treeWithoutReviews, infrastructure }; } let validate = null; if (branch) { diff --git a/test/task-packet.test.mjs b/test/task-packet.test.mjs index 14812341..be2f629e 100644 --- a/test/task-packet.test.mjs +++ b/test/task-packet.test.mjs @@ -17,10 +17,12 @@ test('права выводятся из статусной метки по пр assert.ok(rightsFor('S6-in-progress', ['blocked'])[0].startsWith('blocked')); assert.ok(rightsFor('S7-code-review', ['review-4'])[0].startsWith('review-4')); assert.ok(rightsFor(null).some((l) => l.includes('продуктовая задача вне процесса'))); - const infrastructure = rightsFor(null, ['infra']); + const infrastructure = rightsFor(null, ['infra'], { infrastructure: true }); assert.ok(infrastructure.some((l) => l.includes('инфраструктурную реализацию МОЖНО'))); assert.ok(infrastructure.some((l) => l.includes('S7-code-review'))); assert.ok(infrastructure.every((l) => !l.includes('продуктовый код трогать МОЖНО'))); + assert.ok(rightsFor('S6-in-progress', ['infra']).some((l) => l.includes('продуктовый код трогать МОЖНО')), + 'the thematic infra label alone must not override an S6 product status'); }); test('AC распознаются из таблицы ТЗ и из строк тела issue (#496)', () => { @@ -77,7 +79,7 @@ test('пакет собирается и рендерится: статус, м labels: ['P2', 'feature', 'S6-in-progress', 'small'], comments: [{ author: 'Matysh', body: '## Решения владельца\n…', createdAt: '2026-09-08T13:35:00Z' }, { author: 'claude[bot]', body: 'Вердикт: зелёный · заход r3 · High: 0 · Medium: 0', createdAt: '2026-09-08T17:37:00Z' }], - branch: { name: 'issue/437-summary-panel', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 3, behind: 0, treeWithoutReviews: TREE }, + branch: { name: 'issue/437-summary-panel', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 3, behind: 0, treeWithoutReviews: TREE, infrastructure: false }, reviewDocs: [{ name: 'CODE-REVIEW-437-r3.md', text: doc }], validate: { status: 'зелёный', url: 'https://run' }, }); @@ -98,6 +100,7 @@ test('#562: statusless infra issue is the accelerated track ending at S7 review' const packet = buildPacket({ issue: { number: 562, title: 'process', state: 'OPEN', url: 'u', body: '' }, labels: ['P1', 'infra', 'process', 'tech-debt'], + branch: { name: 'issue/562-process', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: true }, }); assert.equal(packet.status, null); assert.equal(packet.track, 'инфраструктурный'); @@ -106,6 +109,16 @@ test('#562: statusless infra issue is the accelerated track ending at S7 review' assert.match(md, /S7-code-review/); }); +test('#562: the infra label alone never grants the accelerated track', () => { + const packet = buildPacket({ + issue: { number: 999, title: 'mislabeled product', state: 'OPEN', url: 'u', body: '' }, + labels: ['infra', 'S6-in-progress'], + branch: { name: 'issue/999-product', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: false }, + }); + assert.equal(packet.track, 'полный'); + assert.ok(packet.rights.some((l) => l.includes('продуктовый код трогать МОЖНО'))); +}); + test('#517 AC5: AC берутся из тела issue, файл ТЗ — только когда в теле их нет', () => { const base = { issue: { number: 700, title: 'x', state: 'OPEN', url: 'u', body: '## ТЗ\n\n- AC1. Из тела\n' },