process: infrastructure without a track label runs as show (#695 r1)

CODE-REVIEW-695-r1 Medium: PROCESS §5.1 says an infrastructure task (§1)
without a track label reads as track:show, but neither the pipeline guard
nor the task packet did that.

- _process.yml guard: with no track:* and no small/trivial label, the
  diff of the task branch against dev (compare API) with no class A file
  gives the show cycle limit 2. A truncated compare answer (300 files)
  proves nothing and keeps the limit 4.
- task-packet.mjs: an infrastructure packet names the track it runs on:
  «инфраструктурный · show» without a label, the owner's label otherwise.
- Mutants guard-infra-keeps-ask-limit and
  packet-infra-track-ignores-show-default.

Issue: #695
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
Claude
2026-09-28 22:50:19 +03:00
parent 51854aea53
commit f6c76b7351
5 changed files with 81 additions and 5 deletions
+20
View File
@@ -220,6 +220,26 @@ jobs:
- документы: $new_blocking"
fi
echo "ветка материала: ${branch:-нет, читался dev}"
# §5.1 (r1 #695): инфраструктурная задача (§1) без трековой метки
# идёт как `track:show` — лимит 2. Признак механический, как в §1:
# в диффе ветки против dev ни одного файла класса A. Guard историю
# не клонирует, поэтому пути — из compare API; 300 файлов — потолок
# ответа, и полный список тогда не доказан: остаётся лимит 4.
if [ -n "$branch" ] && ! has track:ship && ! has track:show && ! has track:ask \
&& ! has small && ! has trivial; then
changed=$(mktemp)
if gh api "repos/$REPO/compare/dev...$branch" --jq '.files[].filename' > "$changed" 2>/dev/null \
&& node --input-type=module -e '
import { readFileSync } from "node:fs";
import { classify } from "./scripts/process-gate.mjs";
const files = readFileSync(process.argv[1], "utf8").split("\n").filter(Boolean);
process.exit(files.length > 0 && files.length < 300 && files.every((f) => classify(f) !== "A") ? 0 : 1);
' "$changed"; then
limit=2
echo "инфраструктурная задача без трековой метки — трек show (§5.1), лимит 2"
fi
fi
fi
# Отказ обязан быть виден в issue, а не только в логе прогона.
+23
View File
@@ -13334,6 +13334,29 @@ const MUTANT_DEFINITIONS = [
replace: ' if True: # mutant: concurrent flushes are not serialised\n',
}],
},
// r1 #695: инфраструктура без трековой метки — трек show (§5.1).
{
id: 'guard-infra-keeps-ask-limit',
guard: 'node --test --test-name-pattern="r1 #695" test/review-doc-guard.test.mjs',
because: 'r1 #695: an infrastructure task without a track label reads as track:show (PROCESS §5.1); '
+ 'the guard must give it the show cycle limit 2, not the ask limit 4',
patches: [{
file: '.github/workflows/_process.yml',
find: ' limit=2\n echo "инфраструктурная задача без трековой метки',
replace: ' limit=4 # mutant: infra keeps the ask limit\n echo "инфраструктурная задача без трековой метки',
}],
},
{
id: 'packet-infra-track-ignores-show-default',
guard: 'node --test --test-name-pattern="r1 #695" test/task-packet.test.mjs',
because: 'r1 #695: the packet names the track an infrastructure task actually runs on — show '
+ 'without a label, the owner label otherwise',
patches: [{
file: 'scripts/task-packet.mjs',
find: " const infraTrack = hasTrackLabel(labels) ? trackFromLabels(labels) : 'show';",
replace: ' const infraTrack = trackFromLabels(labels); // mutant: unlabelled infra reads as ask',
}],
},
];
const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8');
+10 -2
View File
@@ -141,6 +141,10 @@ export function trackFromLabels(labels = []) {
return 'ask';
}
/** Есть ли у задачи трековая метка — новая или прежняя (§5.1). */
export const hasTrackLabel = (labels = []) => ['track:ship', 'track:show', 'track:ask', 'trivial', 'small']
.some((label) => labels.includes(label));
/**
* Признаки продуктового S-flow (#632). Инфраструктурная задача входит в поток
* сразу на S7 и никогда не несёт S1–S5, ТЗ и ревью ТЗ; поэтому любой из этих
@@ -177,9 +181,13 @@ export function buildPacket(inputs) {
// продуктового потока.
const infrastructure = branch?.infrastructure === true && productFlow.length === 0;
const infrastructureHint = branch == null && status == null && labels.includes('infra') && productFlow.length === 0;
// §5.1 (r1 #695): инфраструктурная задача без трековой метки идёт как
// `show`; явная метка владельца главнее. Маршрут при этом остаётся
// инфраструктурным — вход сразу на S7, без S1–S5.
const infraTrack = hasTrackLabel(labels) ? trackFromLabels(labels) : 'show';
const track = infrastructure
? 'инфраструктурный'
: infrastructureHint ? 'инфраструктурный (предварительно; подтвердить путями/diff)'
? `инфраструктурный · ${infraTrack}`
: infrastructureHint ? `инфраструктурный · ${infraTrack} (предварительно; подтвердить путями/diff)`
: trackFromLabels(labels);
const stage = status === 'S4-spec-review' || status === 'S3-spec' || status === 'S5-ready' ? 'spec' : 'code';
const verdict = lastVerdict(comments, reviewDocs, stage);
+12
View File
@@ -968,3 +968,15 @@ test('guard перечисляет docs/reviews деревом, а не contents
// потолка нет.
assert.match(guard, /contents\/docs\/reviews\/\$name\?ref=\$target/);
});
test('r1 #695: guard даёт инфраструктуре без трековой метки лимит show (§5.1)', () => {
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
const guard = workflow.slice(workflow.indexOf('\n guard:'), workflow.indexOf('\n prepare:'));
assert.match(guard, /gh api "repos\/\$REPO\/compare\/dev\.\.\.\$branch" --jq '\.files\[\]\.filename'/,
'признак инфраструктуры — пути диффа ветки против dev');
assert.match(guard, /files\.every\(\(f\) => classify\(f\) !== "A"\)/, 'механический признак §1: ни одного файла класса A');
assert.match(guard, /files\.length < 300/, 'обрезанный ответ compare инфраструктуру не доказывает');
assert.match(guard, /; then\n\s+limit=2\n\s+echo "инфраструктурная задача без трековой метки/);
assert.match(guard, /! has track:ship && ! has track:show && ! has track:ask \\\n\s+&& ! has small && ! has trivial; then/,
'явная метка трека главнее признака инфраструктуры');
});
+16 -3
View File
@@ -2,7 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import {
branchIsInfrastructure, buildPacket, evidenceFor, productFlowEvidence, extractAcceptanceCriteria, lastVerdict, ownerDecisions, renderPacket, rightsFor,
trackFromLabels,
trackFromLabels, hasTrackLabel,
} from '../scripts/task-packet.mjs';
import { materialAnchorBlock } from '../scripts/review-doc-guard.mjs';
@@ -107,7 +107,7 @@ test('#562: statusless infra issue is the accelerated track ending at S7 review'
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, 'инфраструктурный');
assert.equal(packet.track, 'инфраструктурный · show', 'r1 #695: инфраструктура без трековой метки — show (§5.1)');
const md = renderPacket(packet);
assert.match(md, /инфраструктурный вход/);
assert.match(md, /S7-code-review/);
@@ -177,7 +177,7 @@ test('#632: statusless or returned infra issue without spec keeps the class A ba
reviewDocs: [{ name: 'CODE-REVIEW-632-r1.md', text: 'Вердикт: жёлтый' }],
});
assert.deepEqual(packet.productFlow, [], labels.join(','));
assert.equal(packet.track, 'инфраструктурный', labels.join(','));
assert.equal(packet.track, 'инфраструктурный · show', labels.join(','));
assert.ok(packet.rights.some((l) => l.includes('файлы класса A трогать НЕЛЬЗЯ')), labels.join(','));
assert.ok(packet.rights.every((l) => !l.includes('продуктовый код трогать МОЖНО')), labels.join(','));
}
@@ -228,3 +228,16 @@ test('#695: трек по меткам — track:* главнее прежних
assert.equal(trackFromLabels(['bug', 'P2']), 'ask');
assert.equal(trackFromLabels([]), 'ask');
});
test('r1 #695: инфраструктурная задача с явной меткой трека несёт её, без метки — show', () => {
const at = (labels) => buildPacket({
issue: { number: 7, title: 'infra', state: 'OPEN', url: 'u', body: '' },
labels,
branch: { name: 'issue/7-x', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: true },
}).track;
assert.equal(at(['infra']), 'инфраструктурный · show');
assert.equal(at(['infra', 'track:ask']), 'инфраструктурный · ask', 'метка владельца главнее');
assert.equal(at(['infra', 'track:ship']), 'инфраструктурный · ship');
assert.equal(hasTrackLabel(['bug', 'infra']), false);
assert.equal(hasTrackLabel(['small']), true);
});