mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
The owner's report: the process works but every stage takes a long time even on simple bugs. Two causes, and neither was the one that first comes to mind. The reviewer ran everything regardless. On #89 it installed Chromium, ran all 127 smoke files and a full golden capture — right for a task rated 10/10 for complexity, absurd for a bug about a room divider. Full suites are the pre-beta gate; the review now runs typecheck, unit and build always, and smokes, golden, pytest or performance only where the diff and the AC call for them. The price of narrowing it is honesty: the reviewer must list which gates it ran, which it did not, and why, so a skipped gate is a visible decision rather than a silent one. The reviewer also built its own environment out of model turns, with no npm cache and no browser cache, paid for from the same forty-five minutes. The workflow now installs dependencies and Chromium as ordinary cached steps, after switching to the task branch so the lockfile is the branch's own. Second, ceremony did not scale down. The light track makes a spec cheap; the new trivial track does without one — S2-analysis straight to S5-ready, no spec review, AC in the issue body. It is deliberately hard to qualify for: a bug on one surface, no new UX contract, no migration, no i18n, no perf or touch effect, three checkable AC at most, and expected behaviour already on record. Nothing left to decide is the criterion that holds the whole thing up, and it cannot be met by feeling sure. Code review is never skipped on either track. It is what stands in for testing here, so it is the one stage speed may not buy. Issue: #127 Issue: #128 User-Visible: no
327 lines
15 KiB
JavaScript
327 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 и trivial оправдывают отсутствие файла, их отсутствие — нет.
|
|
assert.deepEqual(checkSpecs([c], [], () => ['small', 'S5-ready']), []);
|
|
assert.deepEqual(checkSpecs([c], [], () => ['trivial', '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 });
|
|
}
|
|
});
|