From 10d32312b536a9d31ba28ae0e61ddb79a43a1c35 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 03:43:51 +0300 Subject: [PATCH] task-packet: track follows issue status and history, not a class-A-free diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A product issue in S5-S7 whose branch holds only its spec review was classified as infrastructure and told "class A is forbidden". The track is now decided by product-flow evidence first (S1-S5 status, "## ТЗ" in the body, docs/specs file, spec review doc or verdict); the "diff without class A" heuristic applies only outside the product flow. docs/reviews/** no longer counts as branch material. Three mutants guard both directions. Issue: #632 User-Visible: no --- scripts/mutation-registry.mjs | 33 +++++++++++++++++++++++ scripts/task-packet.mjs | 46 ++++++++++++++++++++++++++++---- test/task-packet.test.mjs | 49 ++++++++++++++++++++++++++++++++++- 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index a7f1fcbf..9dd0a8e3 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -3129,6 +3129,39 @@ const MUTANT_DEFINITIONS = [ replace: " releaseSourceViolations: releaseSourceViolationsOf(sha, files),", }], }, + { + id: 'task-packet-product-flow-overrides-diff', + guard: 'node --test test/task-packet.test.mjs', + because: 'a product S6 issue whose branch holds no class A yet must keep its class A rights; ' + + 'letting the diff heuristic win again prints the false infrastructure ban of #632', + patches: [{ + file: 'scripts/task-packet.mjs', + find: " const infrastructure = branch?.infrastructure === true && productFlow.length === 0;", + replace: " const infrastructure = branch?.infrastructure === true;", + }], + }, + { + id: 'task-packet-review-docs-not-material', + guard: 'node --test test/task-packet.test.mjs', + because: 'docs/reviews/** is written by the pipeline, not by the task: counting it as ' + + 'material makes a spec-review-only product branch look like infrastructure (#632)', + patches: [{ + file: 'scripts/task-packet.mjs', + find: " const material = changedFiles.filter((name) => !name.startsWith('docs/reviews/'));", + replace: " const material = changedFiles;", + }], + }, + { + id: 'task-packet-s6-s7-alone-not-product-flow', + guard: 'node --test test/task-packet.test.mjs', + because: 'infrastructure issues enter at S7 and return to S6, so S6/S7 alone must not ' + + 'count as product flow — otherwise a returned infra task loses the class A ban (#632)', + patches: [{ + file: 'scripts/task-packet.mjs', + find: " if (PRE_CODE_STATUSES.includes(status)) reasons.push(`статус ${status}`);", + replace: " if (STATUS_LABELS.includes(status)) reasons.push(`статус ${status}`);", + }], + }, { id: 'fit-house-hidden-walls-vote', guard: 'node demo/smoke_space_card.mjs', diff --git a/scripts/task-packet.mjs b/scripts/task-packet.mjs index f4e08954..e033c101 100644 --- a/scripts/task-packet.mjs +++ b/scripts/task-packet.mjs @@ -112,16 +112,51 @@ export function lastVerdict(comments, docs, stage) { }; } +/** + * Дифф ветки как доказательство ускоренного инфраструктурного трека (#562). + * Документы ревью (`docs/reviews/**`, класс C) пишет конвейер и сама ветка + * ревью ТЗ — они не материал задачи и классификацию не двигают (#632): ветка + * продуктовой S6-задачи до первого кодового коммита содержит только + * SPEC-REVIEW и не должна выглядеть инфраструктурной. + */ +export function branchIsInfrastructure(changedFiles = []) { + const material = changedFiles.filter((name) => !name.startsWith('docs/reviews/')); + return material.length > 0 && !material.some((name) => classify(name) === 'A'); +} + +const PRE_CODE_STATUSES = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready']; + +/** + * Признаки продуктового S-flow (#632). Инфраструктурная задача входит в поток + * сразу на S7 и никогда не несёт S1–S5, ТЗ и ревью ТЗ; поэтому любой из этих + * признаков делает эвристику «дифф без класса A» неприменимой. S6/S7/S8 сами по + * себе признаком не являются: их носит и инфраструктурная задача после ревью. + */ +export function productFlowEvidence({ status = null, issue = {}, specs = [], reviewDocs = [], comments = [] } = {}) { + const reasons = []; + if (PRE_CODE_STATUSES.includes(status)) reasons.push(`статус ${status}`); + if (/^#{1,3}\s*ТЗ(?![\p{L}\p{N}_])/mu.test(String(issue?.body ?? ''))) reasons.push('раздел «## ТЗ» в теле issue'); + if (specs.length) reasons.push('файл ТЗ в docs/specs'); + if (reviewDocs.some((d) => String(d.name).startsWith('SPEC-REVIEW-'))) reasons.push('документ ревью ТЗ'); + else if (comments.some((c) => verdictDeclaration(c.body) && /SPEC-REVIEW-\d+/.test(String(c.body)))) reasons.push('вердикт ревью ТЗ в комментариях'); + return reasons; +} + export function buildPacket(inputs) { const { issue, labels = [], comments = [], owner = 'Matysh', branch = null, specs = [], reviewDocs = [], validate = null, } = inputs; const status = STATUS_LABELS.find((l) => labels.includes(l)) || null; + // Трек сначала определяется статусом и историей issue (#632): прошедшая + // S3/S4/S5 или несущая ТЗ задача — продуктовая, и её право на класс A в + // S5–S7 не отнимается пустым пока диффом. + const productFlow = productFlowEvidence({ status, issue, specs, reviewDocs, comments }); // `infra` — тематическая метка и не даёт процессных прав. Ускоренный трек // доказывается тем же механическим признаком, что process-gate: в реальном - // diff опубликованной ветки нет ни одного файла класса A. - const infrastructure = branch?.infrastructure === true; - const infrastructureHint = branch == null && status == null && labels.includes('infra'); + // diff опубликованной ветки нет ни одного файла класса A — и только вне + // продуктового потока. + const infrastructure = branch?.infrastructure === true && productFlow.length === 0; + const infrastructureHint = branch == null && status == null && labels.includes('infra') && productFlow.length === 0; const track = infrastructure ? 'инфраструктурный' : infrastructureHint ? 'инфраструктурный (предварительно; подтвердить путями/diff)' @@ -139,7 +174,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, { infrastructure, infrastructureHint }), + status, track, labels, productFlow, rights: rightsFor(status, labels, { infrastructure, infrastructureHint }), decisions: ownerDecisions(comments, owner), material: branch ? { branch: branch.name, tip: branch.tip, base: branch.base, ahead: branch.ahead, behind: branch.behind, @@ -158,6 +193,7 @@ export function renderPacket(p) { L.push(`# Пакет задачи #${p.issue.number} — ${p.issue.title}`); L.push(''); L.push(`Статус: **${p.status || 'без S-метки'}** · трек: ${p.track} · метки: ${p.labels.join(', ') || '—'} · issue ${p.issue.state}`); + if (p.productFlow?.length) L.push(`Продуктовый поток: ${p.productFlow.join(', ')} — дифф без класса A трек не меняет (#632)`); L.push(''); L.push('## Права и следующий шаг'); for (const r of p.rights) L.push(`- ${r}`); @@ -214,7 +250,7 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr 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'); + const infrastructure = branchIsInfrastructure(changedFiles); // Дерево без 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)); diff --git a/test/task-packet.test.mjs b/test/task-packet.test.mjs index df16f96e..5cbca549 100644 --- a/test/task-packet.test.mjs +++ b/test/task-packet.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { - buildPacket, evidenceFor, extractAcceptanceCriteria, lastVerdict, ownerDecisions, renderPacket, rightsFor, + branchIsInfrastructure, buildPacket, evidenceFor, productFlowEvidence, extractAcceptanceCriteria, lastVerdict, ownerDecisions, renderPacket, rightsFor, } from '../scripts/task-packet.mjs'; import { materialAnchorBlock } from '../scripts/review-doc-guard.mjs'; @@ -135,6 +135,53 @@ test('#562: before a branch exists the infra label prompts classification but gr assert.ok(packet.rights.every((l) => !l.includes('продуктовый код трогать МОЖНО'))); }); +test('#632: review documents never classify a branch as infrastructure', () => { + assert.equal(branchIsInfrastructure(['docs/reviews/SPEC-REVIEW-607-r1.md']), false, + 'a product branch holding only its spec review is not an infrastructure diff'); + assert.equal(branchIsInfrastructure(['docs/reviews/SPEC-REVIEW-607-r1.md', 'scripts/task-packet.mjs']), true); + assert.equal(branchIsInfrastructure(['scripts/task-packet.mjs', 'src/space-render.ts']), false); + assert.equal(branchIsInfrastructure([]), false); +}); + +test('#632: product S6 issue keeps class A rights while its diff has no class A yet', () => { + const packet = buildPacket({ + issue: { number: 607, title: 'HA dialog close', state: 'OPEN', url: 'u', body: '## ТЗ\n\n| AC1 | диалог закрывается | смок |\n' }, + labels: ['bug', 'P2', 'S6-in-progress', 'small'], + // Даже если сборщик входов счёл дифф инфраструктурным (старый collectInputs + // или дифф только из class B/C), продуктовый поток сильнее эвристики. + branch: { name: 'issue/607-ha-dialog-close', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: true }, + reviewDocs: [{ name: 'SPEC-REVIEW-607-r1.md', text: 'Вердикт: зелёный' }], + }); + assert.equal(packet.track, 'small'); + assert.ok(packet.rights.some((l) => l.includes('продуктовый код трогать МОЖНО'))); + assert.ok(packet.rights.every((l) => !l.includes('файлы класса A трогать НЕЛЬЗЯ'))); + assert.match(renderPacket(packet), /Продуктовый поток: .*ТЗ/); + + // Каждый признак потока по отдельности достаточен. + const bare = { issue: { number: 1, body: '' } }; + assert.deepEqual(productFlowEvidence({ ...bare, status: 'S5-ready' }), ['статус S5-ready']); + assert.deepEqual(productFlowEvidence({ status: 'S6-in-progress', issue: { body: 'x\n## ТЗ\n- AC1: y' } }), ['раздел «## ТЗ» в теле issue']); + assert.deepEqual(productFlowEvidence({ status: 'S6-in-progress', issue: { body: '## ТЗшка не раздел' } }), []); + assert.equal(productFlowEvidence({ ...bare, status: 'S6-in-progress', specs: [{ name: '1-x.md' }] }).length, 1); + assert.equal(productFlowEvidence({ ...bare, status: 'S7-code-review', reviewDocs: [{ name: 'SPEC-REVIEW-1-r2.md' }] }).length, 1); + assert.equal(productFlowEvidence({ ...bare, comments: [{ body: 'SPEC-REVIEW-1-r1\nВердикт: зелёный · цикл r1/4 · High: 0 · Medium: 0' }] }).length, 1); +}); + +test('#632: statusless or returned infra issue without spec keeps the class A ban', () => { + for (const labels of [['bug', 'infra', 'process'], ['infra', 'S6-in-progress'], ['infra', 'S7-code-review']]) { + const packet = buildPacket({ + issue: { number: 632, title: 'task packet', state: 'OPEN', url: 'u', body: 'Симптом и ожидаемое поведение, без ТЗ.' }, + labels, + branch: { name: 'issue/632-task-packet-track', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: true }, + reviewDocs: [{ name: 'CODE-REVIEW-632-r1.md', text: 'Вердикт: жёлтый' }], + }); + assert.deepEqual(packet.productFlow, [], labels.join(',')); + assert.equal(packet.track, 'инфраструктурный', labels.join(',')); + assert.ok(packet.rights.some((l) => l.includes('файлы класса A трогать НЕЛЬЗЯ')), labels.join(',')); + assert.ok(packet.rights.every((l) => !l.includes('продуктовый код трогать МОЖНО')), labels.join(',')); + } +}); + test('#517 AC5: AC берутся из тела issue, файл ТЗ — только когда в теле их нет', () => { const base = { issue: { number: 700, title: 'x', state: 'OPEN', url: 'u', body: '## ТЗ\n\n- AC1. Из тела\n' },