mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
The pipeline punished what it prescribed: after a failed merge it tells the author to rebase and restore S7-code-review, and that attempt finished the budget. On #225 (light track, limit 2) the sequence yellow, green, rebase produced review-4 on a task whose code review was green and whose CI was green, with no product change after the verdict — the owner had to arbitrate work that was already accepted. A cycle under section 4 is a verdict with blocking findings followed by a return to the author, so only yellow and red verdicts spend the budget now. A green verdict returned nothing and consumes nothing, which also removes any need to mark rebase re-runs specially. Attempts and cycles are now separate quantities. The attempt number keeps naming the document, because two runs sharing a number would overwrite each other's review artefact, while the limit compares blocking cycles only. The exhaustion comment lists the verdicts it counted, and the guard no longer strips review-4 — it reports the recount and leaves the decision with the owner. Rule 7 of the process gate follows: its document threshold rises above the cycle limit, because legitimate attempts can exceed cycles and a threshold equal to the limit would refuse the very rebase the pipeline demands. Issue: #227 User-Visible: no
641 lines
31 KiB
JavaScript
641 lines
31 KiB
JavaScript
#!/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)));
|
||
}
|