mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
Check 2 compared the Issue trailers against whatever branch the working tree happened to be on, over whatever range it was given. Those two are not the same set. After a rebase the CI range widens — `before` points at a discarded commit, the merge-base slides back, and commits that belong to dev arrive carrying other issue numbers. Every one of them then looks like a violation. Running the gate over real history from issue/89 with a dev range produced 26 false refusals out of 26 commits, which would have reddened Validate on the next force-push of any task branch. The rule now reads origin/dev..HEAD for its own verdict and leaves the event range to the other checks. A commit that genuinely carries the wrong trailer for its branch is still caught; the integration test covers both directions. Issue: #105 User-Visible: no
326 lines
15 KiB
JavaScript
326 lines
15 KiB
JavaScript
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);
|
|
|
|
// Проверка 2 судит только коммиты самой ветки. Диапазон из события CI шире:
|
|
// после ребейза merge-base уезжает назад и втягивает коммиты dev с чужими
|
|
// номерами issue. На реальной истории это давало 26 ложных отказов из 26.
|
|
git('checkout', '-q', 'dev');
|
|
git('reset', '-q', '--hard', base);
|
|
write('src/on-dev.ts', 'export const d = 1;\n');
|
|
write('docs/CHANGELOG.md', 'ru\n');
|
|
write('docs/CHANGELOG.ru.md', 'en\n');
|
|
commitAll('Land on dev\n\nIssue: #1\nUser-Visible: yes');
|
|
const devTip = git('rev-parse', 'HEAD').trim();
|
|
git('update-ref', 'refs/remotes/origin/dev', devTip);
|
|
|
|
git('checkout', '-q', '-b', 'issue/2-own-work');
|
|
write('src/on-branch.ts', 'export const b = 1;\n');
|
|
commitAll('Work on the task branch\n\nIssue: #2\nUser-Visible: no');
|
|
|
|
// Диапазон намеренно захватывает коммит dev про #1, пока HEAD на ветке #2.
|
|
const spanning = spawnSync(process.execPath,
|
|
[gate, '--repo', dir, '--range', `${base}..HEAD`, '--json'], { encoding: 'utf8' });
|
|
const spanned = JSON.parse(spanning.stdout);
|
|
assert.equal(spanned.commits, 2);
|
|
assert.deepEqual(spanned.findings.filter((f) => f.rule === 2), [], spanning.stdout);
|
|
assert.equal(spanned.ok, true, spanning.stdout);
|
|
|
|
// А своё же нарушение ветка по-прежнему получает.
|
|
write('src/wrong-issue.ts', 'export const w = 1;\n');
|
|
commitAll('Wrong trailer for this branch\n\nIssue: #3\nUser-Visible: no');
|
|
const wrong = spawnSync(process.execPath,
|
|
[gate, '--repo', dir, '--range', `${devTip}..HEAD`, '--json'], { encoding: 'utf8' });
|
|
const wrongReport = JSON.parse(wrong.stdout);
|
|
assert.equal(wrongReport.findings.some((f) => f.rule === 2), true, wrong.stdout);
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|