fix(process): the #562 infra entry no longer covers an issue in S3/S4 (#753)

Rule 8 skipped the status check for any range without class A files, so a
task in S3-spec or S4-spec-review could push a branch of tests, demo or
scripts with only a warning. §11.8 forbids exactly that: before S5 neither
class A commits nor the branch itself is pushed, because the spec-review
step takes the freshest origin/issue/<NN>-* as its material and lays the
SPEC-REVIEW document there, putting code in front of a spec reviewer who
must not read it (§2.4).

The #562 entry was written for a task before its first S status. The
decision is now made per issue in checkIssueStatuses: the status stays
optional only when the range is infrastructural and the issue carries
neither S3-spec nor S4-spec-review. Such an issue gets a rule 8 refusal
naming its status and §11.8; the range-wide #562 warning is still printed.
No status, S1-new/S2-analysis (reviewer-filed infra issues) and S5-S8 keep
their old outcome; closed, blocked and fail-closed checks are untouched;
rule 10 is still called only for ranges with class A.

PROCESS.md §10.2 gets the one-sentence exception, the mutation registry a
mutant that drops the S3/S4 condition.

Issue: #753
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-10-01 14:03:19 +00:00
committed by claude[bot]
parent 9deed2a3c3
commit 4aa6c85f98
4 changed files with 141 additions and 3 deletions
+3 -1
View File
@@ -1169,7 +1169,9 @@ Validate стартует от этого push и успевает прочит
**Инфраструктурный диапазон статуса не требует** (#562, §1): если в диапазоне нет
ни одного файла класса A, задача ещё не вошла в поток — она войдёт в него сразу на
`S7-code-review`, — и отсутствие S-метки не отказ. Признак механический, по
диффу, а не по метке `infra`.
диффу, а не по метке `infra`. Кроме задачи в `S3-spec` или `S4-spec-review`:
она уже в маршруте ТЗ, её ветка до `S5` не пушится (§11.8), и диапазон без
класса A судится, как любой другой (#753).
**При продвижении в `main` не перепроверяются коммиты, уже достижимые из
prerelease-тега.** После выпуска беты их issue по §2.8 должны быть закрыты, а
+12
View File
@@ -3598,6 +3598,18 @@ const MUTANT_DEFINITIONS = [
replace: " const infrastructure = branch?.infrastructure === true;",
}],
},
{
id: 'process-gate-infra-entry-covers-spec-route',
guard: 'node --test test/process-gate.test.mjs',
because: 'the #562 infrastructure entry is for a task before its first S status; letting it '
+ 'cover S3-spec/S4-spec-review passes a pushed tests-only branch that §11.8 forbids and '
+ 'hands the spec review a branch as material (#753)',
patches: [{
file: 'scripts/process-gate.mjs',
find: ' const optional = statusOptional && specRoute.length === 0;',
replace: ' const optional = statusOptional;',
}],
},
{
id: 'task-packet-review-docs-not-material',
guard: 'node --test test/task-packet.test.mjs',
+18 -2
View File
@@ -411,6 +411,12 @@ export function commitsUnderRuleOne(commits) {
// поставить продуктовой задаче и увести продуктовый коммит от проверки статуса,
// а перестать трогать класс A, не перестав быть инфраструктурной задачей,
// нельзя. Существование issue, его открытость и `blocked` проверяются всё равно.
//
// Признак диапазона не знает, где задача. Задача в маршруте ТЗ (`S3-spec`,
// `S4-spec-review`: `track:ask`, повышенный show после `reclassify` #726, ручной
// возврат) уже не «до первого S-статуса»: её ветка до `S5` не пушится вовсе
// (§11.8), иначе ревью ТЗ получает её материалом. Поэтому исключение снимается
// по каждому issue в `checkIssueStatuses`, а не по диапазону (#753).
export function isInfrastructureRange(commits) {
if (!commits.length) return false;
return !commits.some((c) => c.classes.has('A'));
@@ -450,6 +456,12 @@ export function commitsNeedingIssueStatus(
return underRuleOne.filter((commit) => !isPublishedPrereleaseCommit(commit.sha));
}
// Статусы маршрута ТЗ, для которых инфраструктурный вход #562 не действует
// (#753). `S1-new` и `S2-analysis` в него не входят: инфраструктурные issue,
// заведённые ревьюером, приходят с `S1-new`, а ветка до `S4` материалом ревью
// ТЗ не становится.
export const SPEC_ROUTE_STATUS = ['S3-spec', 'S4-spec-review'];
// 8. статус issue. Fail closed: недоступный или закрытый issue — отказ, а не
// пропуск. Гейт, который молчит при недоступном источнике правды, бесполезен.
export function checkIssueStatuses(
@@ -484,11 +496,15 @@ export function checkIssueStatuses(
continue;
}
const names = (issue.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name));
if (!statusOptional && !names.some((n) => allowed.includes(n))) {
const specRoute = names.filter((n) => SPEC_ROUTE_STATUS.includes(n));
const optional = statusOptional && specRoute.length === 0;
if (!optional && !names.some((n) => allowed.includes(n))) {
const status = names.filter((n) => /^S\d-/.test(n));
out.push({
level: 'fail', rule: 8, sha: '-',
msg: `issue #${nn}: статус ${status.length ? status.join(',') : 'не проставлен'}, а нужен один из ${allowed.join(' / ')}`,
msg: statusOptional
? `issue #${nn} в ${specRoute.join(',')}: ветка задачи до «Готово к разработке» не пушится (§11.8); инфраструктурный вход (#562) — не для задачи в маршруте ТЗ`
: `issue #${nn}: статус ${status.length ? status.join(',') : 'не проставлен'}, а нужен один из ${allowed.join(' / ')}`,
});
}
if (names.includes('blocked')) {
+108
View File
@@ -772,6 +772,54 @@ test('statusOptional permits the pre-S7 infra push but keeps every other rule-8
assert.deepEqual(rules(checkIssueStatuses(['206'], garbage, { statusOptional: true })), [8]);
});
// AC1 #753: вход #562 — для задачи «до первого S-статуса», а не для задачи в
// маршруте ТЗ. В `S3-spec`/`S4-spec-review` ветка до `S5` не пушится (§11.8),
// диапазон без класса A судится, как любой другой. Решение — по каждому issue.
test('statusOptional does not cover an issue in the spec route S3/S4 (#753)', () => {
const issueWith = (...labels) => () => ({
ok: true, json: JSON.stringify({ state: 'OPEN', labels: labels.map((name) => ({ name })) }),
});
const optional = (runner) => checkIssueStatuses(['753'], runner, { statusOptional: true });
const s3 = optional(issueWith('S3-spec', 'P2'));
assert.deepEqual(rules(s3), [8]);
assert.match(s3[0].msg, /#753/);
assert.match(s3[0].msg, /S3-spec/);
assert.match(s3[0].msg, /§11\.8/);
assert.match(s3[0].msg, /#562/);
const s4 = optional(issueWith('S4-spec-review', 'track:ask'));
assert.deepEqual(rules(s4), [8]);
assert.match(s4[0].msg, /S4-spec-review/);
assert.match(s4[0].msg, /§11\.8/);
// Открыты по-прежнему: без статуса, `S1-new`/`S2-analysis` (инфраструктурные
// issue от ревьюера), рабочее множество.
assert.deepEqual(optional(issueWith('bug', 'P2', 'infra')), []);
assert.deepEqual(optional(issueWith('S1-new', 'infra')), []);
assert.deepEqual(optional(issueWith('S2-analysis')), []);
assert.deepEqual(optional(issueWith('S6-in-progress', 'track:show')), []);
// Трек без статуса маршрута ТЗ исключение не снимает.
assert.deepEqual(optional(issueWith('track:ask', 'infra')), []);
// `blocked` по-прежнему отдельной находкой.
assert.deepEqual(rules(optional(issueWith('S4-spec-review', 'blocked'))), [8, 8]);
// Без исключения текст прежний: статус и рабочее множество.
const strict = checkIssueStatuses(['753'], issueWith('S3-spec'));
assert.deepEqual(rules(strict), [8]);
assert.match(strict[0].msg, /нужен один из/);
// Два issue одного диапазона: отказ только у задачи в маршруте ТЗ.
const byNumber = {
753: issueWith('S4-spec-review', 'track:ask'),
206: issueWith('bug', 'P2', 'infra'),
};
const pair = checkIssueStatuses(['753', '206'], (nn) => byNumber[nn](), { statusOptional: true });
assert.deepEqual(rules(pair), [8]);
assert.match(pair[0].msg, /^issue #753 /);
});
// AC #562: сквозной прогон CLI по настоящему репозиторию с подставным gh.
// Диапазон без класса A и с issue без статусной метки обязан быть зелёным;
// тот же диапазон плюс один файл `src/**` — красным по проверке 8.
@@ -837,6 +885,66 @@ test('the CLI permits a pre-S7 class-B-only range but not one with class A (#562
}
});
// AC2 #753: сквозной CLI. Диапазон из одного коммита `test/**` у задачи в
// `S4-spec-review` — отказ п.8; тот же диапазон у задачи в `S1-new` — вход #562.
test('the CLI refuses a class-B-only range of an issue in S3/S4 but not in S1 (#753)', (t) => {
if (process.platform === 'win32') {
t.skip('нужен исполняемый stub gh — прогон в Linux CI');
return;
}
const probe = spawnSync('git', ['--version'], { encoding: 'utf8' });
if (probe.status !== 0) {
t.skip('git недоступен');
return;
}
const dir = mkdtempSync(join(tmpdir(), 'hp-gate-753-'));
const gate = fileURLToPath(new URL('../scripts/process-gate.mjs', import.meta.url));
const git = (...args) => {
const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8' });
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
return r.stdout;
};
// Подставной gh: статус задачи — из окружения прогона.
const ghStub = join(dir, 'gh-stub.mjs');
writeFileSync(ghStub, '#!/usr/bin/env node\n'
+ 'process.stdout.write(JSON.stringify({ number: 753, state: "OPEN", labels: '
+ '[{ name: process.env.HP_STUB_STATUS }, { name: "track:ask" }, { name: "P2" }] }));\n',
{ mode: 0o755 });
const runGate = (range, status) => spawnSync(process.execPath,
[gate, '--repo', dir, '--range', range, '--issues'],
{ encoding: 'utf8', env: { ...process.env, GH_BIN: ghStub, HP_STUB_STATUS: status } });
try {
git('init', '-q', '-b', 'dev');
writeFileSync(join(dir, 'README.md'), 'base\n');
git('add', '-A');
git('-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'core.hooksPath=/dev/null',
'commit', '-q', '-m', 'Base');
const base = git('rev-parse', 'HEAD').trim();
mkdirSync(join(dir, 'test'), { recursive: true });
writeFileSync(join(dir, 'test', 'a.test.mjs'), 'export {};\n');
git('add', '-A');
git('-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'core.hooksPath=/dev/null',
'commit', '-q', '-m', 'Add a test\n\nIssue: #753\nUser-Visible: no');
for (const status of ['S4-spec-review', 'S3-spec']) {
const refused = runGate(`${base}..HEAD`, status);
assert.equal(refused.status, 1, refused.stdout + refused.stderr);
assert.match(refused.stdout, /FAIL п\.8/);
assert.match(refused.stdout, new RegExp(`#753 в ${status}`));
// Пропуск диапазона по-прежнему виден, отказ — отдельной находкой.
assert.match(refused.stdout, /инфраструктурный диапазон \(#562\)/);
}
const entry = runGate(`${base}..HEAD`, 'S1-new');
assert.equal(entry.status, 0, entry.stdout + entry.stderr);
assert.match(entry.stdout, /инфраструктурный диапазон \(#562\)/);
assert.doesNotMatch(entry.stdout, /FAIL/);
} finally {
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),