From 7ba2de7c894e04f7de5eba0afae8622adb9d19fb Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Thu, 13 Aug 2026 14:01:05 +0300 Subject: [PATCH] ci: process gate as a script and a Validate job MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROCESS.md 10.2 describes scripts/process-gate.mjs; the script never existed. Commits go straight to dev without PRs and GitHub blocks nothing on its side, so until now the only thing standing between the process and rule #1 was the good faith of whoever was committing. Hooks catch a violation on the author's machine but --no-verify walks past them; this job is the catch-up pass that cannot be skipped locally. Checks 1-7 offline, 8 through gh, plus the escalation of check 3: a class A commit with neither a spec file nor the `small` label is a failure, not a warning. Check 8 is fail closed — an unreachable or closed issue is a refusal, never a silent pass. Two things surfaced while wiring it up and are recorded in the script header. S8-merged had to join the allowed statuses: the pipeline merges into dev before it moves the label, so Validate reads the issue already advanced and a strict set would redden every accepted task. And the status question now applies only to class A/B commits — asking it of a review document would fail every time, since that document lands while the issue sits in S4-spec-review or S7-code-review. Issue: #105 User-Visible: no --- .github/workflows/validate.yml | 24 ++ scripts/process-gate.mjs | 463 +++++++++++++++++++++++++++++++++ test/process-gate.test.mjs | 293 +++++++++++++++++++++ 3 files changed, 780 insertions(+) create mode 100644 scripts/process-gate.mjs create mode 100644 test/process-gate.test.mjs diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index c99bf22b..ad6ad811 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -32,6 +32,30 @@ jobs: run: | node scripts/validate-commit-provenance.mjs --check-hook-mode --github-range + # Догоняющая проверка процесса (PROCESS.md §10.3). Хуки ловят нарушение на + # машине автора, но их можно обойти `--no-verify`, а коммиты идут прямо в dev + # без PR — GitHub на своей стороне не блокирует ничего. Это последнее место, + # где нарушение правила №1 ловится машиной. Job независимый: краснеет сам и + # не роняет остальные, откат — удалить его отсюда, скрипт остаётся рабочим. + process-gate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - uses: actions/setup-node@v4 + with: { node-version: 22 } + - name: Process gate + env: + EVENT_NAME: ${{ github.event_name }} + BEFORE_SHA: ${{ github.event.before }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.sha }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + # Публичный репозиторий: штатного токена хватает на чтение issue. + GH_TOKEN: ${{ github.token }} + run: | + node scripts/process-gate.mjs --github-range --issues + hacs: runs-on: ubuntu-latest steps: diff --git a/scripts/process-gate.mjs b/scripts/process-gate.mjs new file mode 100644 index 00000000..43a57477 --- /dev/null +++ b/scripts/process-gate.mjs @@ -0,0 +1,463 @@ +#!/usr/bin/env node +// Гейт процесса houseplan-card. Проверяет диапазон коммитов по PROCESS.md §10.2. +// Node, без зависимостей. Ничего не изменяет — только читает git, файлы и issue. +// +// node scripts/process-gate.mjs --range .. +// node scripts/process-gate.mjs --github-range # диапазон из события CI +// node scripts/process-gate.mjs --issues # + проверка 8 через gh +// node scripts/process-gate.mjs --json # машинный вывод +// node scripts/process-gate.mjs --report # печатать, но не краснеть +// +// Код выхода: 0 — чисто, 1 — есть нарушения, 2 — не смог проверить. +// +// Зачем гейт нужен: коммиты идут прямо в dev, без PR, и GitHub на своей стороне +// не блокирует ничего (§10). Это единственное место, где нарушение правила №1 +// может быть поймано машиной, а не добросовестностью агента. +// +// Решения, принятые при реализации. Продуктового поведения не касаются, поэтому +// приняты здесь, а не у владельца; ревьюер вправе оспорить любое. +// +// 1. «Release vX.Y.Z-beta.N candidate» — НЕ релизный коммит. Promotion-only по +// AGENTS.md — это стабильный релиз; кандидат беты несёт саму работу и живёт +// по общим правилам, включая трейлер Issue. +// 2. Классы дополнены: package.json, package-lock.json, pytest.ini, .gitignore, +// .gitattributes, .githooks/** — класс B (конфигурация сборки и гейтов). +// CONTRIBUTING.md, PROCESS*.md, CODE-REVIEW-*.md, SPEC-REVIEW-*.md — класс C. +// §1 их не перечисляет вовсе; без этого они попадали в «путь вне классов». +// 3. Документы ревью ищутся и в docs/reviews/, и в корне: три штуки закоммичены +// в корень до того, как появилась договорённость о каталоге. +// 4. `S8-merged` входит в множество допустимых статусов, хотя issue #105 +// предлагал обратное. Причина выяснилась при реализации: конвейер сначала +// сливает ветку в dev, а метку ставит после — Validate успевает прочитать +// issue уже в `S8-merged`, и строгое множество красило бы каждую принятую +// задачу. Локально строгость возвращается флагом `--no-merged`. + +import { spawnSync } from 'node:child_process'; +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { resolveValidationRange } from './validate-commit-provenance.mjs'; + +// --- классы изменений, PROCESS.md §1 --- +// Порядок важен: D проверяется первым, иначе собранный бандл попадёт в A, +// а demo/golden/baselines — в B. +const CLASS_D = [ + /^dist\//, + /^custom_components\/houseplan\/frontend\//, + /^demo\/srv\/assets\/houseplan-card\.js$/, + /^demo\/golden\/baselines\//, +]; +const CLASS_A = [ + /^src\//, + /^custom_components\/houseplan\/.*\.py$/, + /^hacs\.json$/, + /^custom_components\/.*\/manifest\.json$/, + /^custom_components\/.*\/translations\//, +]; +const CLASS_B = [ + /^test\//, /^tests_backend\//, /^demo\//, /^scripts\//, + /^\.github\//, /^\.githooks\//, /^rollup\.config\.mjs$/, /^tsconfig.*\.json$/, + /^package(-lock)?\.json$/, /^pytest\.ini$/, /^\.gitignore$/, /^\.gitattributes$/, +]; +const CLASS_C = [ + /^docs\//, /^README/, /^CHANGELOG/, /^AGENTS\.md$/, /^LICENSE$/, + /^CONTRIBUTING\.md$/, /^PROCESS.*\.md$/, /^(CODE|SPEC)-REVIEW-.*\.md$/, +]; + +const CHANGELOGS = ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md']; + +export const ALLOWED_STATUS = ['S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged']; +export const STRICT_STATUS = ['S5-ready', 'S6-in-progress', 'S7-code-review']; + +export const RULES = { + 0: 'классификация путей', + 1: 'трейлер Issue', + 2: 'имя ветки', + 3: 'ТЗ для класса A', + 4: 'User-Visible и changelog', + 5: 'класс D без основания', + 6: 'релизный коммит', + 7: 'лимит документов ревью', + 8: 'статус issue', + 9: 'Gates: light', +}; + +export function classify(path) { + if (CLASS_D.some((r) => r.test(path))) return 'D'; + if (CLASS_A.some((r) => r.test(path))) return 'A'; + if (CLASS_B.some((r) => r.test(path))) return 'B'; + if (CLASS_C.some((r) => r.test(path))) return 'C'; + return '?'; +} + +// --- разбор коммитов --- +// Тело коммита многострочное, поэтому поля режутся не по переводам строк: +// %x1f разделяет поля, %x1e — записи. Разбор по строкам ломался на первом же +// коммите с абзацем в теле. +export const FS = '\x1f'; +export const RS = '\x1e'; +export const LOG_FORMAT = `%H${FS}%s${FS}%b${RS}`; + +export function makeCommit({ sha = '', subject = '', body = '', files = [] }) { + const text = `${subject}\n${body}`; + const all = (name) => + [...text.matchAll(new RegExp(`^${name}:\\s*(.+)$`, 'gmi'))].map((m) => m[1].trim()); + const one = (name) => all(name)[0] ?? null; + return { + sha, + short: sha.slice(0, 8), + subject, + files, + classes: new Set(files.map(classify)), + issues: all('Issue'), + userVisible: one('User-Visible'), + release: one('Release'), + baselineReviewed: one('Baseline-Reviewed'), + gates: one('Gates'), + // Кандидат беты несёт работу и живёт по общим правилам — решение 1. + isRelease: + (/^Release v\d/.test(subject) && !/-(beta|rc|alpha)\.|candidate/i.test(subject)) + || Boolean(one('Release')), + }; +} + +export function parseRecords(raw, filesOf = () => []) { + if (!raw.trim()) return []; + return raw + .split(RS) + .map((r) => r.replace(/^\n/, '')) + .filter((r) => r.trim()) + .map((rec) => { + const [sha, subject, body = ''] = rec.split(FS); + return makeCommit({ sha, subject, body, files: filesOf(sha) }); + }); +} + +// --- проверки по одному коммиту: 1, 4, 5, 6, 9 --- +export function evaluateCommit(c) { + const out = []; + const fail = (rule, msg) => out.push({ level: 'fail', rule, sha: c.short, msg }); + const warn = (rule, msg) => out.push({ level: 'warn', rule, sha: c.short, msg }); + const has = (k) => c.classes.has(k); + const onlyD = c.classes.size === 1 && has('D'); + const sources = c.files.filter( + (f) => /^src\//.test(f) || /^custom_components\/houseplan\/.*\.py$/.test(f), + ); + + if (c.isRelease) { + // Релизный коммит: §10.2 п.5 и п.6. Трейлер Issue от него не требуется — + // публикация версии не продуктовое изменение. + if (!c.release) { + fail(5, `релизный коммит «${c.subject.slice(0, 50)}» без трейлера «Release: vX.Y.Z»`); + } + if (sources.length) { + fail(6, `релизный коммит содержит продуктовый исходник: ${sources.slice(0, 3).join(', ')}`); + } + if ((c.gates ?? '').toLowerCase() === 'light') { + fail(9, '«Gates: light» на релизном коммите запрещён'); + } + return out; + } + + if ((has('A') || has('B')) && !c.issues.length) { + fail(1, `класс ${has('A') ? 'A' : 'B'} без трейлера «Issue: #NN» — ${c.subject.slice(0, 60)}`); + } + for (const t of c.issues) { + if (!/^#\d+$/.test(t)) fail(1, `трейлер Issue должен быть вида «#NN», получено «${t}»`); + } + + if ((c.userVisible ?? '').toLowerCase() === 'yes') { + const missing = CHANGELOGS.filter((f) => !c.files.includes(f)); + if (missing.length) fail(4, `User-Visible: yes, но не тронуты: ${missing.join(', ')}`); + } + if ((has('A') || has('B')) && !c.userVisible) { + warn(4, 'класс A/B без трейлера «User-Visible: yes|no»'); + } + + if (onlyD && !c.release && !c.baselineReviewed) { + fail(5, 'изменена только генерируемая часть (класс D) без «Release: vX.Y.Z» либо «Baseline-Reviewed: <ссылка>»'); + } + + if ((c.gates ?? '').toLowerCase() === 'light' && onlyD) { + fail(9, '«Gates: light» на коммите класса D запрещён'); + } + + const unknown = c.files.filter((f) => classify(f) === '?'); + if (unknown.length) warn(0, `путь вне классов A/B/C/D: ${unknown.slice(0, 3).join(', ')}`); + + return out; +} + +// 2. имя ветки issue/NN-slug соответствует трейлерам +export function checkBranchRule(branch, commits) { + const m = (branch ?? '').match(/^issue\/(\d+)-/); + if (!m) return []; + const want = `#${m[1]}`; + const out = []; + for (const c of commits) { + for (const t of c.issues) { + if (t !== want) { + out.push({ + level: 'fail', rule: 2, sha: c.short, + msg: `ветка ${branch} про ${want}, а трейлер указывает ${t}`, + }); + } + } + } + return out; +} + +// 3. для класса A нужно ТЗ docs/specs/NN-*.md, либо метка small (лёгкий трек). +// Офлайн это предупреждение: лёгкий трек держит ТЗ в теле issue, и без чтения +// меток отличить «ТЗ в issue» от «ТЗ не написано» невозможно. С метками — отказ. +export function checkSpecs(commits, specFiles, labelsOf = null) { + if (specFiles === null) { + return [{ level: 'warn', rule: 3, sha: '-', msg: 'нет docs/specs/ — проверка 3 пропущена' }]; + } + const out = []; + const seen = new Set(); + for (const c of commits) { + if (!c.classes.has('A') || c.isRelease) continue; + for (const t of c.issues) { + const nn = t.slice(1); + if (seen.has(nn)) continue; + seen.add(nn); + if (specFiles.some((f) => new RegExp(`^0*${nn}[-_]`).test(f))) continue; + + const labels = labelsOf ? labelsOf(nn) : null; + if (labels === null) { + out.push({ + level: 'warn', rule: 3, sha: c.short, + msg: `класс A по ${t}, но ТЗ docs/specs/${nn}-*.md не найдено — допустимо только при метке small`, + }); + } else if (!labels.includes('small')) { + out.push({ + level: 'fail', rule: 3, sha: c.short, + msg: `класс A по ${t}: ТЗ docs/specs/${nn}-*.md нет, и метки small на issue нет — код без ТЗ`, + }); + } + } + } + return out; +} + +// 7. документов ревью на issue не больше четырёх (§4, лимит циклов) +export function checkReviewDocLimit(files) { + if (!files.length) { + return [{ level: 'warn', rule: 7, sha: '-', msg: 'документов ревью не найдено — проверка 7 пропущена' }]; + } + const byIssue = new Map(); + for (const f of files) { + const m = f.match(/-(\d+)-r(\d+)\.md$/) || f.match(/(\d+).*-r(\d+)\.md$/); + if (!m) continue; + const arr = byIssue.get(m[1]) ?? []; + arr.push(Number(m[2])); + byIssue.set(m[1], arr); + } + const out = []; + for (const [nn, rounds] of byIssue) { + if (Math.max(...rounds) > 4 || rounds.length > 4) { + out.push({ + level: 'fail', rule: 7, sha: '-', + msg: `issue #${nn}: документов ревью ${rounds.length}, максимум r${Math.max(...rounds)} — лимит 4 цикла исчерпан`, + }); + } + } + return out; +} + +// Правило №1 говорит о продуктовом коде и инструментах, а не о документации. +// Поэтому статус issue спрашивается только у коммитов класса A/B. Иначе краснел +// бы каждый документ ревью: он ложится в ветку, пока issue в S4-spec-review или +// S7-code-review, и рабочего статуса у задачи в этот момент нет. +export function commitsUnderRuleOne(commits) { + return commits.filter( + (c) => !c.isRelease && (c.classes.has('A') || c.classes.has('B')), + ); +} + +// 8. статус issue. Fail closed: недоступный или закрытый issue — отказ, а не +// пропуск. Гейт, который молчит при недоступном источнике правды, бесполезен. +export function checkIssueStatuses(numbers, runner, { allowed = ALLOWED_STATUS } = {}) { + const out = []; + for (const nn of numbers) { + const r = runner(nn); + if (!r || r.ok !== true) { + const why = (r && r.error ? String(r.error) : 'нет ответа').split('\n')[0]; + out.push({ + level: 'fail', rule: 8, sha: '-', + msg: `issue #${nn}: не удалось прочитать — отказ (fail closed). ${why}`, + }); + continue; + } + let issue; + try { + issue = typeof r.json === 'string' ? JSON.parse(r.json) : r.json; + } catch (e) { + out.push({ + level: 'fail', rule: 8, sha: '-', + msg: `issue #${nn}: ответ не разобран — отказ (fail closed). ${e.message}`, + }); + continue; + } + if (String(issue.state).toUpperCase() !== 'OPEN') { + out.push({ + level: 'fail', rule: 8, sha: '-', + msg: `issue #${nn} закрыт — коммит по закрытой задаче отклоняется`, + }); + continue; + } + const names = (issue.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)); + if (!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(' / ')}`, + }); + } + if (names.includes('blocked')) { + out.push({ + level: 'fail', rule: 8, sha: '-', + msg: `issue #${nn} помечен blocked — задача ждёт владельца`, + }); + } + } + return out; +} + +export function buildReport({ range, branch, commits, findings }) { + const fails = findings.filter((f) => f.level === 'fail'); + return { + range, + branch, + commits, + ok: fails.length === 0, + fails: fails.length, + warns: findings.length - fails.length, + findings, + }; +} + +// --- CLI --- +function git(args, repo) { + const r = spawnSync('git', ['-C', repo, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); + if (r.status !== 0) { + process.stderr.write(`git ${args.join(' ')} → ${(r.stderr || '').trim()}\n`); + process.exit(2); + } + return r.stdout; +} + +function ghRunner(nwo, bin) { + return (nn) => { + const r = spawnSync(bin, ['issue', 'view', String(nn), '--repo', nwo, '--json', 'number,state,labels'], + { encoding: 'utf8' }); + return r.status === 0 + ? { ok: true, json: r.stdout } + : { ok: false, error: (r.stderr || r.stdout || 'gh завершился с ошибкой').trim() }; + }; +} + +function main(argv) { + const flag = (name) => argv.includes(`--${name}`); + const value = (name, dflt = null) => { + const i = argv.indexOf(`--${name}`); + if (i === -1) return dflt; + const next = argv[i + 1]; + return !next || next.startsWith('--') ? true : next; + }; + + const repo = value('repo', process.cwd()); + const allowed = flag('no-merged') ? STRICT_STATUS : ALLOWED_STATUS; + + let range = value('range'); + if (!range && flag('github-range')) { + range = resolveValidationRange({ + eventName: process.env.EVENT_NAME, + beforeSha: process.env.BEFORE_SHA, + baseSha: process.env.BASE_SHA, + headSha: process.env.HEAD_SHA, + defaultBranch: process.env.DEFAULT_BRANCH, + }, (args) => git(args, repo).trim()); + } + if (!range) { + const hasDev = spawnSync('git', ['-C', repo, 'rev-parse', '--verify', 'origin/dev'], + { encoding: 'utf8' }).status === 0; + range = hasDev ? 'origin/dev..HEAD' : 'HEAD~20..HEAD'; + } + + const filesOf = (sha) => + git(['show', '--name-only', '--pretty=format:', sha], repo) + .split('\n').map((s) => s.trim()).filter(Boolean); + const commits = parseRecords( + git(['log', '--reverse', `--pretty=format:${LOG_FORMAT}`, range], repo), filesOf, + ); + const branch = git(['rev-parse', '--abbrev-ref', 'HEAD'], repo).trim(); + + const findings = []; + for (const c of commits) findings.push(...evaluateCommit(c)); + findings.push(...checkBranchRule(branch, commits)); + + // Метки читаются один раз и используются дважды: проверкой 8 и escalation + // проверки 3. Второй запрос по тому же issue — лишний сетевой вызов. + let labelsOf = null; + if (flag('issues')) { + const numbers = [...new Set(commitsUnderRuleOne(commits).flatMap((c) => c.issues).map((t) => t.slice(1)))]; + const runner = ghRunner(process.env.HP_REPO ?? 'Matysh/houseplan-card', process.env.GH_BIN ?? 'gh'); + const cache = new Map(); + const cached = (nn) => { + if (!cache.has(nn)) cache.set(nn, runner(nn)); + return cache.get(nn); + }; + findings.push(...checkIssueStatuses(numbers, cached, { allowed })); + labelsOf = (nn) => { + const r = cached(nn); + if (!r || r.ok !== true) return null; + try { + const issue = typeof r.json === 'string' ? JSON.parse(r.json) : r.json; + return (issue.labels ?? []).map((l) => (typeof l === 'string' ? l : l.name)); + } catch { + return null; + } + }; + } + + const specsDir = join(repo, 'docs', 'specs'); + findings.push(...checkSpecs(commits, existsSync(specsDir) ? readdirSync(specsDir) : null, labelsOf)); + + const reviewDir = join(repo, 'docs', 'reviews'); + const reviewFiles = [ + ...(existsSync(reviewDir) ? readdirSync(reviewDir) : []), + ...readdirSync(repo).filter((f) => /^(CODE|SPEC)-REVIEW-.*\.md$/.test(f)), + ]; + findings.push(...checkReviewDocLimit(reviewFiles)); + + const report = buildReport({ range, branch, commits: commits.length, findings }); + + if (flag('json')) { + process.stdout.write(`${JSON.stringify(report, null, 2)}\n`); + } else { + process.stdout.write(`process-gate: диапазон ${range}, коммитов ${commits.length}\n`); + for (const f of findings.filter((x) => x.level === 'fail')) { + process.stdout.write(`FAIL п.${f.rule} ${RULES[f.rule]} ${f.sha} ${f.msg}\n`); + } + for (const f of findings.filter((x) => x.level === 'warn')) { + process.stdout.write(`WARN п.${f.rule} ${RULES[f.rule]} ${f.sha} ${f.msg}\n`); + } + process.stdout.write(report.ok + ? `гейт пройден, предупреждений ${report.warns}\n` + : `нарушений ${report.fails}, предупреждений ${report.warns}\n`); + if (!flag('issues')) { + process.stdout.write('проверка 8 (статус issue) не выполнялась — добавьте --issues\n'); + } + } + + // --report печатает находки, но не краснеет: нужен для догоняющего прогона по + // исторической части, где нарушения известны и чиниться не будут. + return flag('report') ? 0 : (report.ok ? 0 : 1); +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + process.exit(main(process.argv.slice(2))); +} diff --git a/test/process-gate.test.mjs b/test/process-gate.test.mjs new file mode 100644 index 00000000..acd9da8c --- /dev/null +++ b/test/process-gate.test.mjs @@ -0,0 +1,293 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { + ALLOWED_STATUS, + STRICT_STATUS, + buildReport, + checkBranchRule, + checkIssueStatuses, + checkReviewDocLimit, + checkSpecs, + classify, + commitsUnderRuleOne, + evaluateCommit, + makeCommit, + parseRecords, + FS, + RS, +} from '../scripts/process-gate.mjs'; + +const commit = (subject, body, files) => makeCommit({ sha: 'deadbeefcafe', subject, body, files }); +const rules = (findings) => findings.filter((f) => f.level === 'fail').map((f) => f.rule); + +test('paths classify into A/B/C/D with the generated tree winning over source', () => { + assert.equal(classify('src/houseplan-card.ts'), 'A'); + assert.equal(classify('custom_components/houseplan/api.py'), 'A'); + // Собранный бандл лежит внутри custom_components — класс D должен победить. + assert.equal(classify('custom_components/houseplan/frontend/houseplan-card.js'), 'D'); + assert.equal(classify('demo/golden/baselines/view.png'), 'D'); + assert.equal(classify('dist/houseplan-card.js'), 'D'); + assert.equal(classify('test/canvas.test.mjs'), 'B'); + assert.equal(classify('.github/workflows/validate.yml'), 'B'); + assert.equal(classify('package-lock.json'), 'B'); + assert.equal(classify('docs/SCOPE.md'), 'C'); + assert.equal(classify('PROCESS.md'), 'C'); + assert.equal(classify('CODE-REVIEW-111-r1.md'), 'C'); + assert.equal(classify('something-unheard-of.xyz'), '?'); +}); + +test('a clean class A commit produces no failures', () => { + const c = commit('Fix empty plan render', 'Issue: #111\nUser-Visible: yes', [ + 'src/houseplan-card.ts', 'docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md', + ]); + assert.deepEqual(rules(evaluateCommit(c)), []); + assert.deepEqual(c.issues, ['#111']); + assert.equal(c.isRelease, false); +}); + +test('class A/B without an Issue trailer fails rule 1', () => { + assert.deepEqual(rules(evaluateCommit(commit('Fix thing', 'User-Visible: no', ['src/a.ts']))), [1]); + assert.deepEqual(rules(evaluateCommit(commit('Tune CI', 'User-Visible: no', ['.github/workflows/x.yml']))), [1]); + // Класс C живёт без issue — документация не требует задачи. + assert.deepEqual(rules(evaluateCommit(commit('Reword docs', '', ['docs/README.md']))), []); +}); + +test('a malformed Issue trailer fails even when present', () => { + assert.deepEqual(rules(evaluateCommit(commit('Fix', 'Issue: 111\nUser-Visible: no', ['src/a.ts']))), [1]); +}); + +test('several Issue trailers are all collected', () => { + const c = commit('Fix three things', 'Issue: #75\nIssue: #95\nIssue: #98\nUser-Visible: no', ['src/a.ts']); + assert.deepEqual(c.issues, ['#75', '#95', '#98']); + assert.deepEqual(rules(evaluateCommit(c)), []); +}); + +test('User-Visible: yes demands both changelogs in the same commit', () => { + assert.deepEqual( + rules(evaluateCommit(commit('Fix', 'Issue: #1\nUser-Visible: yes', ['src/a.ts', 'docs/CHANGELOG.md']))), + [4], + ); + assert.deepEqual( + rules(evaluateCommit(commit('Fix', 'Issue: #1\nUser-Visible: yes', [ + 'src/a.ts', 'docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md', + ]))), + [], + ); +}); + +test('a class D only commit needs a release or a reviewed baseline', () => { + const bare = commit('Rebuild bundle', 'Issue: #1', ['dist/houseplan-card.js']); + assert.deepEqual(rules(evaluateCommit(bare)), [5]); + const reviewed = commit('Accept baselines', 'Issue: #1\nBaseline-Reviewed: https://example/run/1', [ + 'demo/golden/baselines/a.png', + ]); + assert.deepEqual(rules(evaluateCommit(reviewed)), []); +}); + +test('beta candidates are ordinary commits, stable releases are not', () => { + // Решение 1: кандидат беты несёт работу, поэтому трейлер Issue обязателен. + const beta = commit('Release v1.62.0-beta.8 candidate', '', ['src/a.ts']); + assert.equal(beta.isRelease, false); + assert.deepEqual(rules(evaluateCommit(beta)), [1]); + + const stable = commit('Release v1.62.0', 'Release: v1.62.0', ['dist/houseplan-card.js']); + assert.equal(stable.isRelease, true); + assert.deepEqual(rules(evaluateCommit(stable)), []); +}); + +test('a release commit carrying product source fails rule 6', () => { + const bad = commit('Release v1.62.0', 'Release: v1.62.0', ['src/a.ts', 'dist/houseplan-card.js']); + assert.deepEqual(rules(evaluateCommit(bad)), [6]); +}); + +test('Gates: light is refused on release and generated commits', () => { + assert.deepEqual( + rules(evaluateCommit(commit('Release v1.62.0', 'Release: v1.62.0\nGates: light', ['dist/a.js']))), + [9], + ); + assert.deepEqual( + rules(evaluateCommit(commit('Rebuild', 'Issue: #1\nBaseline-Reviewed: x\nGates: light', ['dist/a.js']))), + [9], + ); +}); + +test('the branch name must agree with the Issue trailers', () => { + const c = commit('Fix', 'Issue: #104', ['src/a.ts']); + assert.deepEqual(rules(checkBranchRule('issue/104-opening-ha-reference', [c])), []); + assert.deepEqual(rules(checkBranchRule('issue/111-empty-plan', [c])), [2]); + // dev и main под правило не попадают. + assert.deepEqual(checkBranchRule('dev', [c]), []); +}); + +test('a class A commit without a spec warns offline and fails with labels', () => { + const c = commit('Fix', 'Issue: #104', ['src/a.ts']); + assert.deepEqual(checkSpecs([c], ['104-opening-ha-reference.md']), []); + + // Офлайн отличить «ТЗ в теле issue» от «ТЗ нет» нельзя — только предупреждение. + const offline = checkSpecs([c], ['111-something-else.md']); + assert.equal(offline.length, 1); + assert.equal(offline[0].level, 'warn'); + assert.equal(offline[0].rule, 3); + + // С метками: small оправдывает отсутствие файла, его отсутствие — нет. + assert.deepEqual(checkSpecs([c], [], () => ['small', 'S5-ready']), []); + const strict = checkSpecs([c], [], () => ['S5-ready']); + assert.equal(strict.length, 1); + assert.equal(strict[0].level, 'fail'); + assert.equal(strict[0].rule, 3); + + // Метки недоступны — падать обратно на предупреждение: за недоступность + // отвечает проверка 8, она уже краснеет fail closed. + assert.equal(checkSpecs([c], [], () => null)[0].level, 'warn'); +}); + +test('more than four review documents on one issue exhausts the cycle limit', () => { + assert.deepEqual(rules(checkReviewDocLimit([ + 'CODE-REVIEW-104-r1.md', 'CODE-REVIEW-104-r2.md', + ])), []); + assert.deepEqual(rules(checkReviewDocLimit([ + 'CODE-REVIEW-104-r1.md', 'CODE-REVIEW-104-r2.md', + 'CODE-REVIEW-104-r3.md', 'CODE-REVIEW-104-r4.md', 'CODE-REVIEW-104-r5.md', + ])), [7]); +}); + +test('only class A/B commits are held to the issue status', () => { + // Документ ревью ложится в ветку, пока задача в S4-spec-review или + // S7-code-review: рабочего статуса в этот момент нет, и спрашивать его нельзя. + const reviewDoc = commit('docs: review document for #104', 'Issue: #104\nUser-Visible: no', [ + 'docs/reviews/CODE-REVIEW-104-r1.md', + ]); + const code = commit('Fix', 'Issue: #104\nUser-Visible: no', ['src/a.ts']); + const release = commit('Release v1.62.0', 'Release: v1.62.0', ['dist/a.js']); + + assert.deepEqual(commitsUnderRuleOne([reviewDoc, code, release]).map((c) => c.subject), ['Fix']); + assert.deepEqual(commitsUnderRuleOne([reviewDoc]), []); +}); + +test('issue status check is fail closed when the source of truth is unreachable', () => { + // AC3: недоступный gh должен давать отказ, а не молчаливый пропуск. + const broken = () => ({ ok: false, error: 'gh: could not resolve to a Repository' }); + const found = checkIssueStatuses(['104'], broken); + 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(['104'], garbage)), [8]); +}); + +test('issue status check accepts the working statuses and refuses the rest', () => { + const withLabels = (labels, state = 'OPEN') => () => ({ ok: true, json: JSON.stringify({ state, labels }) }); + for (const status of ALLOWED_STATUS) { + assert.deepEqual(rules(checkIssueStatuses(['1'], withLabels([{ name: status }]))), [], status); + } + assert.deepEqual(rules(checkIssueStatuses(['1'], withLabels([{ name: 'S2-analysis' }]))), [8]); + assert.deepEqual(rules(checkIssueStatuses(['1'], withLabels([]))), [8]); + assert.deepEqual(rules(checkIssueStatuses(['1'], withLabels([{ name: 'S5-ready' }], 'CLOSED'))), [8]); + // blocked дополняет статус, а не заменяет — и всё равно останавливает работу. + assert.deepEqual( + rules(checkIssueStatuses(['1'], withLabels([{ name: 'S5-ready' }, { name: 'blocked' }]))), + [8], + ); + // --no-merged возвращает строгое множество: S8-merged перестаёт проходить. + assert.deepEqual( + rules(checkIssueStatuses(['1'], withLabels([{ name: 'S8-merged' }]), { allowed: STRICT_STATUS })), + [8], + ); +}); + +test('the log record parser survives multi-line commit bodies', () => { + // Разбор по строкам ломался здесь: тело содержит пустые строки и абзацы. + const raw = [ + `aaaaaaaaaaaa${FS}First subject${FS}Some prose.\n\nMore prose.\n\nIssue: #1\nUser-Visible: no\n${RS}`, + `bbbbbbbbbbbb${FS}Second subject${FS}Issue: #2\nUser-Visible: yes\n${RS}`, + ].join(''); + const list = parseRecords(raw, () => ['src/a.ts']); + assert.equal(list.length, 2); + assert.deepEqual(list.map((c) => c.issues), [['#1'], ['#2']]); + assert.equal(list[0].subject, 'First subject'); + assert.deepEqual(parseRecords('', () => []), []); +}); + +test('the JSON report keeps the shape later workflows read', () => { + // AC5: форма объекта — часть контракта, её ломать нельзя молча. + const findings = [ + { level: 'fail', rule: 1, sha: 'abc', msg: 'x' }, + { level: 'warn', rule: 0, sha: 'abc', msg: 'y' }, + ]; + const report = buildReport({ range: 'a..b', branch: 'dev', commits: 2, findings }); + assert.deepEqual(Object.keys(report).sort(), + ['branch', 'commits', 'fails', 'findings', 'ok', 'range', 'warns']); + assert.equal(report.ok, false); + assert.equal(report.fails, 1); + assert.equal(report.warns, 1); + assert.equal(buildReport({ range: 'a..b', branch: 'dev', commits: 0, findings: [] }).ok, true); +}); + +// AC1: сквозной прогон CLI по настоящему репозиторию — заведомо чистый коммит +// даёт 0, заведомо битый даёт 1. Проверяет то, чего не видят юнит-тесты: разбор +// git log, обход файлов, код выхода. +test('the CLI exits 0 on a clean range and 1 on a broken one', (t) => { + const probe = spawnSync('git', ['--version'], { encoding: 'utf8' }); + if (probe.status !== 0) { + t.skip('git недоступен'); + return; + } + const dir = mkdtempSync(join(tmpdir(), 'hp-gate-')); + const gate = new URL('../scripts/process-gate.mjs', import.meta.url).pathname; + 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); + }; + const runGate = (range) => spawnSync(process.execPath, [gate, '--repo', dir, '--range', range], { + encoding: 'utf8', + }); + + try { + git('init', '-q', '-b', 'dev'); + write('README.md', 'base\n'); + commitAll('Base'); + const base = git('rev-parse', 'HEAD').trim(); + + write('src/a.ts', 'export const a = 1;\n'); + write('docs/CHANGELOG.md', 'ru\n'); + write('docs/CHANGELOG.ru.md', 'en\n'); + commitAll('Add a\n\nIssue: #1\nUser-Visible: yes'); + const clean = runGate(`${base}..HEAD`); + assert.equal(clean.status, 0, clean.stdout + clean.stderr); + + write('src/b.ts', 'export const b = 2;\n'); + commitAll('Add b without provenance'); + const broken = runGate(`${base}..HEAD`); + assert.equal(broken.status, 1, broken.stdout + broken.stderr); + assert.match(broken.stdout, /FAIL п\.1/); + + // --report печатает то же, но не краснеет. + const report = spawnSync(process.execPath, + [gate, '--repo', dir, '--range', `${base}..HEAD`, '--report'], { encoding: 'utf8' }); + assert.equal(report.status, 0, report.stdout + report.stderr); + + const asJson = spawnSync(process.execPath, + [gate, '--repo', dir, '--range', `${base}..HEAD`, '--json'], { encoding: 'utf8' }); + const parsed = JSON.parse(asJson.stdout); + assert.equal(parsed.ok, false); + assert.equal(parsed.commits, 2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});