From ad8e7a50cc39813c4a5afb5c21fd7c4b28c26b86 Mon Sep 17 00:00:00 2001 From: Matysh Date: Wed, 19 Aug 2026 20:39:07 +0300 Subject: [PATCH] fix: waive the issue status for a class-A-free range in the process gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rule 8 demanded a working S-label from every class A/B commit's issue, while owner decision #118 sends infrastructure work outside the S1..S8 flow entirely — such an issue has no status label by construction. The two rules contradicted each other and the machine-checked one won, so Validate on dev went red on every infrastructure commit (#175, #191, #202, #206) and the catch-up signal stopped meaning anything. A gate that is always red is not a gate. The waiver keys on the diff, not on a permission label: a range with no class A file at all. An `infra` label could be pinned on a product task to walk a product commit past the status check; ceasing to touch class A without ceasing to be infrastructure work is not possible. Issue existence, open state, `blocked` and fail-closed on an unreachable gh all still apply, and the waiver prints a visible warning rather than passing in silence. Mutation-checked both ways: unwiring the waiver reddens the CLI test, and letting class A keep the waiver reddens both new tests. Issue: #207 User-Visible: no --- scripts/process-gate.mjs | 32 +++++++++- test/process-gate.test.mjs | 119 +++++++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 3 deletions(-) diff --git a/scripts/process-gate.mjs b/scripts/process-gate.mjs index 494b530c..ab2af4f3 100644 --- a/scripts/process-gate.mjs +++ b/scripts/process-gate.mjs @@ -289,6 +289,21 @@ export function commitsUnderRuleOne(commits) { ); } +// Инфраструктурная работа по решению владельца #118: признак механический — +// в диапазоне НЕТ ни одного файла класса A. Такая задача идёт вне продуктового +// флоу и по построению не имеет статусной метки, поэтому проверка 8 требовала у +// неё невозможного и краснела на каждом инфра-коммите (#207): Validate на dev +// был красным систематически, и сигнал догоняющей проверки обесценился. +// +// Исключение опирается на diff, а не на метку-разрешение: метку `infra` можно +// поставить продуктовой задаче и увести продуктовый коммит от проверки статуса, +// а перестать трогать класс A, не перестав быть инфраструктурной задачей, +// нельзя. Существование issue, его открытость и `blocked` проверяются всё равно. +export function isInfrastructureRange(commits) { + if (!commits.length) return false; + return !commits.some((c) => c.classes.has('A')); +} + export function isStableTarget(targetRef) { return /^(?:refs\/heads\/)?main$/.test(targetRef ?? ''); } @@ -325,7 +340,9 @@ export function commitsNeedingIssueStatus( // 8. статус issue. Fail closed: недоступный или закрытый issue — отказ, а не // пропуск. Гейт, который молчит при недоступном источнике правды, бесполезен. -export function checkIssueStatuses(numbers, runner, { allowed = ALLOWED_STATUS } = {}) { +export function checkIssueStatuses( + numbers, runner, { allowed = ALLOWED_STATUS, statusOptional = false } = {}, +) { const out = []; for (const nn of numbers) { const r = runner(nn); @@ -355,7 +372,7 @@ export function checkIssueStatuses(numbers, runner, { allowed = ALLOWED_STATUS } continue; } const names = (issue.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)); - if (!names.some((n) => allowed.includes(n))) { + if (!statusOptional && !names.some((n) => allowed.includes(n))) { const status = names.filter((n) => /^S\d-/.test(n)); out.push({ level: 'fail', rule: 8, sha: '-', @@ -544,7 +561,16 @@ function main(argv) { if (!cache.has(nn)) cache.set(nn, runner(nn)); return cache.get(nn); }; - findings.push(...checkIssueStatuses(numbers, cached, { allowed })); + const statusOptional = isInfrastructureRange(checkedCommits); + if (statusOptional && numbers.length) { + // Пропуск обязан быть виден: иначе исключение однажды скроет настоящее + // нарушение и никто не узнает, что проверка не выполнялась. + findings.push({ + level: 'warn', rule: 8, sha: '-', + msg: `инфраструктурный диапазон (#118): файлов класса A нет, статусная метка issue не требуется`, + }); + } + findings.push(...checkIssueStatuses(numbers, cached, { allowed, statusOptional })); labelsOf = (nn) => { const r = cached(nn); if (!r || r.ok !== true) return null; diff --git a/test/process-gate.test.mjs b/test/process-gate.test.mjs index 00d49c82..685c4e56 100644 --- a/test/process-gate.test.mjs +++ b/test/process-gate.test.mjs @@ -20,6 +20,7 @@ import { commitsNeedingTargetValidation, commitsUnderRuleOne, evaluateCommit, + isInfrastructureRange, makeCommit, parseRecords, FS, @@ -508,3 +509,121 @@ test('the CLI judges a rebased issue branch by its own commits (#190)', (t) => { rmSync(dir, { recursive: true, force: true }); } }); + +test('an infrastructure range is recognised by the absence of class A files (#207)', () => { + const infra = makeCommit({ + sha: 'a'.repeat(40), subject: 'Tune CI', body: 'Issue: #206\nUser-Visible: no', + files: ['.github/workflows/validate.yml'], + }); + const docs = makeCommit({ + sha: 'b'.repeat(40), subject: 'Reword', body: '', files: ['docs/PROCESS.md'], + }); + const product = makeCommit({ + sha: 'c'.repeat(40), subject: 'Fix render', body: 'Issue: #150\nUser-Visible: yes', + files: ['src/a.ts', 'docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md'], + }); + + assert.equal(isInfrastructureRange([infra]), true); + assert.equal(isInfrastructureRange([infra, docs]), true); + // Один файл класса A лишает диапазон исключения целиком: «в основном + // инфраструктурная» не бывает, иначе продуктовая правка минует проверку. + assert.equal(isInfrastructureRange([infra, product]), false); + assert.equal(isInfrastructureRange([product]), false); + // Пустой диапазон исключением не пользуется — нечему быть инфраструктурным. + assert.equal(isInfrastructureRange([]), false); +}); + +test('statusOptional waives the status label but keeps every other rule-8 refusal (#207)', () => { + // Метки инфраструктурного issue по #118: тип, приоритет, тема — без S*. + const infraIssue = () => ({ + ok: true, json: JSON.stringify({ state: 'OPEN', labels: [ + { name: 'bug' }, { name: 'P2' }, { name: 'infra' }, + ] }), + }); + assert.deepEqual(rules(checkIssueStatuses(['206'], infraIssue)), [8]); + assert.deepEqual( + rules(checkIssueStatuses(['206'], infraIssue, { statusOptional: true })), [], + ); + + // Исключение снимает ТОЛЬКО требование статуса. + const closed = () => ({ ok: true, json: JSON.stringify({ state: 'CLOSED', labels: [{ name: 'infra' }] }) }); + assert.deepEqual(rules(checkIssueStatuses(['206'], closed, { statusOptional: true })), [8]); + + const blocked = () => ({ ok: true, json: JSON.stringify({ state: 'OPEN', labels: [ + { name: 'infra' }, { name: 'blocked' }, + ] }) }); + assert.deepEqual(rules(checkIssueStatuses(['206'], blocked, { statusOptional: true })), [8]); + + const unreachable = () => ({ ok: false, error: 'gh: could not resolve to a Repository' }); + const found = checkIssueStatuses(['206'], unreachable, { statusOptional: true }); + assert.deepEqual(rules(found), [8]); + assert.match(found[0].msg, /fail closed/); + + const garbage = () => ({ ok: true, json: 'not json at all' }); + assert.deepEqual(rules(checkIssueStatuses(['206'], garbage, { statusOptional: true })), [8]); +}); + +// AC #207: сквозной прогон CLI по настоящему репозиторию с подставным gh. +// Диапазон без класса A и с issue без статусной метки обязан быть зелёным; +// тот же диапазон плюс один файл `src/**` — красным по проверке 8. +test('the CLI waives the issue status for a class-B-only range but not with class A (#207)', (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-207-')); + 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; + }; + const write = (rel, text) => { + const full = join(dir, rel); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, text); + }; + const commitAll = (message) => { + git('add', '-A'); + git('-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'core.hooksPath=/dev/null', + 'commit', '-q', '-m', message); + }; + // Подставной gh: инфраструктурный issue по #118 — тип, приоритет, тема, без S*. + const ghStub = join(dir, 'gh-stub.mjs'); + writeFileSync(ghStub, '#!/usr/bin/env node\n' + + 'process.stdout.write(JSON.stringify({ number: 206, state: "OPEN", labels: ' + + '[{ name: "bug" }, { name: "P2" }, { name: "infra" }] }));\n', { mode: 0o755 }); + const runGate = (range) => spawnSync(process.execPath, + [gate, '--repo', dir, '--range', range, '--issues'], + { encoding: 'utf8', env: { ...process.env, GH_BIN: ghStub } }); + + try { + git('init', '-q', '-b', 'dev'); + write('README.md', 'base\n'); + commitAll('Base'); + const base = git('rev-parse', 'HEAD').trim(); + + write('.github/workflows/validate.yml', 'name: Validate\n'); + commitAll('Tune CI\n\nIssue: #206\nUser-Visible: no'); + const infraOnly = runGate(`${base}..HEAD`); + assert.equal(infraOnly.status, 0, infraOnly.stdout + infraOnly.stderr); + // Пропуск виден в выводе, а не молчалив. + assert.match(infraOnly.stdout, /инфраструктурный диапазон/); + + // Один продуктовый файл — и требование статуса возвращается. + write('src/a.ts', 'export const a = 1;\n'); + write('docs/CHANGELOG.md', 'ru\n'); + write('docs/CHANGELOG.ru.md', 'en\n'); + commitAll('Fix render\n\nIssue: #206\nUser-Visible: yes'); + const withProduct = runGate(`${base}..HEAD`); + assert.equal(withProduct.status, 1, withProduct.stdout + withProduct.stderr); + assert.match(withProduct.stdout, /FAIL п\.8/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});