mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
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:
+3
-1
@@ -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 должны быть закрыты, а
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user