mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 12:18:51 +00:00
ci: process gate as a script and a Validate job
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
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -0,0 +1,463 @@
|
||||
#!/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'];
|
||||
|
||||
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)));
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user