Files
houseplan-card/scripts/process-gate.mjs
T
2026-08-20 23:49:12 +03:00

641 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Гейт процесса houseplan-card. Проверяет диапазон коммитов по PROCESS.md §10.2.
// Node, без зависимостей. Ничего не изменяет — только читает git, файлы и issue.
//
// node scripts/process-gate.mjs --range <base>..<head>
// 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'];
// Метки, при которых файла ТЗ в docs/specs/ быть не должно: на лёгком треке ТЗ
// живёт в теле issue (§5), на коротком — там же, и ревью ТЗ вообще не проводится
// (§5.1, issue #128). Офлайн эти случаи неотличимы от «ТЗ не написано», поэтому
// проверка 3 краснеет только когда метки прочитаны.
export const NO_SPEC_FILE = ['small', 'trivial'];
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 соответствует трейлерам.
//
// Судить можно только коммиты САМОЙ ветки. Диапазон, который приходит из события
// CI, шире: после ребейза `before` указывает на снесённый коммит, merge-base
// уезжает назад, и в диапазон попадают коммиты `dev` с чужими номерами issue —
// каждый из них выглядел бы нарушением. Проверено на реальной истории: сидя на
// issue/89 с диапазоном по dev, гейт дал 26 ложных отказов из 26 коммитов.
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 или trivial`,
});
} else if (!labels.some((l) => NO_SPEC_FILE.includes(l))) {
out.push({
level: 'fail', rule: 3, sha: c.short,
msg: `класс A по ${t}: ТЗ docs/specs/${nn}-*.md нет, и метки ${NO_SPEC_FILE.join(' / ')} на issue нет — код без ТЗ`,
});
}
}
}
return out;
}
// 7. документов ревью на issue не больше шести.
//
// Порог НЕ равен лимиту циклов (§4), и это не небрежность. Документ нумеруется
// по ЗАХОДУ ревью, а бюджет §4 тратят только вердикты с блокирующими находками
// (#227): зелёное ревью, слияние которого не удалось, требует повторного захода
// после ребейза — это другой код (§2.10) — но цикла не образует. Значит заходов
// законно бывает больше, чем циклов, и порог, равный лимиту, превращал бы
// разрешённый ребейз в отказ гейта. Ровно этот класс противоречия — правило
// против собственной проверки — и разбирался в #227.
//
// Шесть = четыре цикла плюс два ребейза. Не бесконечность: пятнадцать
// документов на одном issue означают, что что-то пошло не так, и это стоит
// увидеть.
export const REVIEW_DOC_LIMIT = 6;
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) > REVIEW_DOC_LIMIT || rounds.length > REVIEW_DOC_LIMIT) {
out.push({
level: 'fail', rule: 7, sha: '-',
msg: `issue #${nn}: документов ревью ${rounds.length}, максимум r${Math.max(...rounds)} — больше ${REVIEW_DOC_LIMIT} заходов на один issue`,
});
}
}
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')),
);
}
// Инфраструктурная работа по решению владельца #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 ?? '');
}
export function isDevTarget(targetRef) {
return /^(?:refs\/heads\/)?dev$/.test(targetRef ?? '');
}
// A main-only infrastructure commit is already published and already passed
// the process gate. When main is merged back into dev, `old-dev..merge` walks
// that second parent as if it were fresh issue-branch work. Requiring its
// closed issue to become active again makes the mandatory main -> dev
// reconciliation impossible. Exclude only commits proven reachable from the
// remote main ref, and only while the destination itself is dev. The merge
// commit and every genuinely new commit remain in the checked set.
export function commitsNeedingTargetValidation(
commits, { targetRef = '', isCommitOnMain = () => false } = {},
) {
if (!isDevTarget(targetRef)) return commits;
return commits.filter((commit) => !isCommitOnMain(commit.sha));
}
// При stable promotion диапазон main..candidate закономерно содержит коммиты,
// уже выпущенные prerelease-тегом. Их issue к этому моменту обязаны быть закрыты
// (§2.8), поэтому повторная online-проверка статуса дала бы ложный отказ. Новые
// post-beta коммиты остаются в выборке и проверяются fail-closed как обычно.
export function commitsNeedingIssueStatus(
commits, { targetRef = '', isPublishedPrereleaseCommit = () => false } = {},
) {
const underRuleOne = commitsUnderRuleOne(commits);
if (!isStableTarget(targetRef)) return underRuleOne;
return underRuleOne.filter((commit) => !isPublishedPrereleaseCommit(commit.sha));
}
// 8. статус issue. Fail closed: недоступный или закрытый issue — отказ, а не
// пропуск. Гейт, который молчит при недоступном источнике правды, бесполезен.
export function checkIssueStatuses(
numbers, runner, { allowed = ALLOWED_STATUS, statusOptional = false } = {},
) {
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 (!statusOptional && !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() };
};
}
// Диапазон issue-ветки приводится к её собственным коммитам (#190).
//
// pre-push передаёт remote_old..local_new. Для fast-forward это ровно новые
// коммиты, но после обязательного ребейза issue-ветки (возврат из конфликтного
// слияния, §10.4) remote_old перестаёт быть предком local_new, и git log
// показывает всё, что недостижимо из старой вершины, — включая ушедшую вперёд
// историю dev. На #117 это дало 84 чужих коммита и 20 ложных отказов п.8 по
// уже закрытым issue, а единственным выходом оставался `--no-verify`.
//
// Правится здесь, а не в самом хуке: `.githooks/pre-push` несёт бит исполнения,
// который публикация через MCP сбрасывает (прецедент commit-msg), — правка хука
// потребовала бы локального коммита владельца. Заодно это второй страховочный
// слой для любого вызывающего с широким диапазоном, включая CI.
//
// Fail-closed сохраняется: собственные коммиты ветки относительно origin/dev
// проверяются все до одного, сужается только чужая история.
export function clampIssueBranchRange(range, { targetRef = '', isAncestor, mergeBaseWithDev } = {}) {
if (!/^(?:refs\/heads\/)?issue\/\d+-/.test(targetRef ?? '')) return range;
const m = /^([^.\s]+)\.\.([^.\s]+)$/.exec(range ?? '');
if (!m) return range;
const [, base, head] = m;
if (isAncestor(base, head)) return range;
const mergeBase = mergeBaseWithDev(head);
if (!mergeBase) return range;
return `${mergeBase}..${head}`;
}
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;
const targetRef = value('target-ref', process.env.TARGET_REF ?? '');
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,
developmentBranch: process.env.DEVELOPMENT_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';
}
range = clampIssueBranchRange(range, {
targetRef,
isAncestor: (base, head) => spawnSync(
'git', ['-C', repo, 'merge-base', '--is-ancestor', base, head], { encoding: 'utf8' },
).status === 0,
mergeBaseWithDev: (head) => {
const r = spawnSync('git', ['-C', repo, 'merge-base', head, 'refs/remotes/origin/dev'],
{ encoding: 'utf8' });
return r.status === 0 ? r.stdout.trim() : null;
},
});
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 hasOriginMain = spawnSync(
'git', ['-C', repo, 'rev-parse', '--verify', 'refs/remotes/origin/main'],
{ encoding: 'utf8' },
).status === 0;
const mainCache = new Map();
const isCommitOnMain = (sha) => {
if (!hasOriginMain) return false;
if (!mainCache.has(sha)) {
mainCache.set(sha, spawnSync(
'git', ['-C', repo, 'merge-base', '--is-ancestor', sha, 'refs/remotes/origin/main'],
{ encoding: 'utf8' },
).status === 0);
}
return mainCache.get(sha);
};
const checkedCommits = commitsNeedingTargetValidation(
commits, { targetRef, isCommitOnMain },
);
const findings = [];
for (const c of checkedCommits) findings.push(...evaluateCommit(c));
// Проверке 2 отдаются только коммиты самой ветки: origin/dev..HEAD, а не
// диапазон события. См. комментарий у checkBranchRule.
const ownCommits = /^issue\/\d+-/.test(branch)
? (() => {
const hasDev = spawnSync('git', ['-C', repo, 'rev-parse', '--verify', 'origin/dev'],
{ encoding: 'utf8' }).status === 0;
if (!hasDev) return checkedCommits;
const own = parseRecords(
git(['log', '--reverse', `--pretty=format:${LOG_FORMAT}`, 'origin/dev..HEAD'], repo),
);
return commitsNeedingTargetValidation(own, { targetRef, isCommitOnMain });
})()
: checkedCommits;
findings.push(...checkBranchRule(branch, ownCommits));
// Метки читаются один раз и используются дважды: проверкой 8 и escalation
// проверки 3. Второй запрос по тому же issue — лишний сетевой вызов.
let labelsOf = null;
if (flag('issues')) {
const prereleaseTags = isStableTarget(targetRef)
? git(['tag', '--list'], repo).split('\n').map((s) => s.trim()).filter((tag) =>
/^v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)-[0-9A-Za-z.-]+$/.test(tag))
: [];
const publishedCache = new Map();
const isPublishedPrereleaseCommit = (sha) => {
if (!publishedCache.has(sha)) {
publishedCache.set(sha, prereleaseTags.some((tag) =>
spawnSync('git', ['-C', repo, 'merge-base', '--is-ancestor', sha, `${tag}^{commit}`],
{ encoding: 'utf8' }).status === 0));
}
return publishedCache.get(sha);
};
const statusCommits = commitsNeedingIssueStatus(checkedCommits, {
targetRef, isPublishedPrereleaseCommit,
});
const numbers = [...new Set(statusCommits.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);
};
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;
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(
checkedCommits, 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)));
}