task-packet: track follows issue status and history, not a class-A-free diff

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
This commit is contained in:
Claude
2026-09-24 02:52:09 +00:00
committed by claude[bot]
parent c8f9b5d3b4
commit 10d32312b5
3 changed files with 122 additions and 6 deletions
+33
View File
@@ -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',
+41 -5
View File
@@ -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));
+48 -1
View File
@@ -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' },