Files
houseplan-card/test/process-gate.test.mjs
T
Claudeandclaude[bot] 4aa6c85f98 fix(process): the #562 infra entry no longer covers an issue in S3/S4 (#753)
Rule 8 skipped the status check for any range without class A files, so a
task in S3-spec or S4-spec-review could push a branch of tests, demo or
scripts with only a warning. §11.8 forbids exactly that: before S5 neither
class A commits nor the branch itself is pushed, because the spec-review
step takes the freshest origin/issue/<NN>-* as its material and lays the
SPEC-REVIEW document there, putting code in front of a spec reviewer who
must not read it (§2.4).

The #562 entry was written for a task before its first S status. The
decision is now made per issue in checkIssueStatuses: the status stays
optional only when the range is infrastructural and the issue carries
neither S3-spec nor S4-spec-review. Such an issue gets a rule 8 refusal
naming its status and §11.8; the range-wide #562 warning is still printed.
No status, S1-new/S2-analysis (reviewer-filed infra issues) and S5-S8 keep
their old outcome; closed, blocked and fail-closed checks are untouched;
rule 10 is still called only for ranges with class A.

PROCESS.md §10.2 gets the one-sentence exception, the mutation registry a
mutant that drops the S3/S4 condition.

Issue: #753
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 14:03:19 +00:00

1523 lines
76 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
ALLOWED_STATUS,
STRICT_STATUS,
buildReport,
checkBranchRule,
checkCommitEraStatuses,
isPipelineReviewDocCommit,
checkIssueStatuses,
checkReviewDocLimit,
REVIEW_DOC_LIMIT,
checkFrozenSpecs,
checkSpecs,
clampIssueBranchRange,
classify,
gitSpecReviewReader,
commitsNeedingIssueStatus,
commitsNeedingTargetValidation,
commitsUnderRuleOne,
evaluateCommit,
isInfrastructureRange,
isReleaseVersionOnlyChange,
makeCommit,
parseRecords,
FS,
RS,
} from '../scripts/process-gate.mjs';
import { issueBodyDigest, materialAnchorBlock } from '../scripts/review-doc-guard.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('scripts/support-relay/relay.py'), '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('legacy/reviews/v1.77.0/CODE-REVIEW-111-r1.md'), 'C'); // #682
assert.equal(classify('legacy/README.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)), []);
const reviewedLocally = commit('Accept baselines', `Issue: #1\nBaseline-Reviewed-Local: sha256:${'a'.repeat(64)}`, [
'demo/golden/baselines/a.png',
]);
assert.deepEqual(rules(evaluateCommit(reviewedLocally)), []);
});
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('a release commit allows only proven canonical version declarations', () => {
const path = 'src/houseplan-card.ts';
const before = "const CARD_VERSION = '1.69.0-beta.5';\nexport const value = 1;\n";
const after = "const CARD_VERSION = '1.69.0';\nexport const value = 1;\n";
assert.equal(isReleaseVersionOnlyChange(path, before, after), true);
assert.equal(isReleaseVersionOnlyChange(
path, before, "const CARD_VERSION = '1.69.0';\nexport const value = 2;\n",
), false);
assert.equal(isReleaseVersionOnlyChange('src/another.ts', before, after), false);
const stable = makeCommit({
sha: 'deadbeefcafe',
subject: 'Release v1.69.0',
body: 'Release: v1.69.0',
files: [path, 'src/houseplan-editor-runtime.ts', 'custom_components/houseplan/const.py'],
releaseSourceViolations: [],
});
assert.deepEqual(rules(evaluateCommit(stable)), []);
const mixed = makeCommit({
sha: 'deadbeefcafe',
subject: 'Release v1.69.0',
body: 'Release: v1.69.0',
files: [path, 'src/houseplan-editor-runtime.ts', 'custom_components/houseplan/const.py'],
releaseSourceViolations: ['src/houseplan-card.ts'],
});
assert.deepEqual(rules(evaluateCommit(mixed)), [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('#517 AC3: ТЗ класса A судится по телу issue, архивный файл тоже годится', () => {
const c = commit('Fix', 'Issue: #104', ['src/a.ts']);
// Архивный файл ТЗ старой задачи — по-прежнему ТЗ.
assert.deepEqual(checkSpecs([c], ['104-opening-ha-reference.md']), []);
// Тело с разделом «## ТЗ» либо с AC1 — это ТЗ, файла не требуется.
assert.deepEqual(checkSpecs([c], [], () => '## ТЗ\n\nконтракт'), []);
assert.deepEqual(checkSpecs([c], [], () => 'Проблема\n\n- AC1. Так и так'), []);
assert.deepEqual(checkSpecs([c], null, () => '### ТЗ (лёгкий трек)'), []);
// Ни того, ни другого — предупреждение: настоящий рубеж — ревью ТЗ.
const bare = checkSpecs([c], ['111-something-else.md'], () => 'просто описание бага');
assert.equal(bare.length, 1);
assert.equal(bare[0].level, 'warn');
assert.equal(bare[0].rule, 3);
assert.match(bare[0].msg, /в теле issue нет/);
// Офлайн тела нет — судить нечем, молчим (метки больше ничего не решают).
assert.deepEqual(checkSpecs([c], [], () => null), []);
assert.deepEqual(checkSpecs([c], null), []);
});
test('#517: docs/specs заморожен — новый файл ТЗ даёт предупреждение, правка старого нет', () => {
const added = { ...commit('Spec', 'Issue: #700', ['docs/specs/700-new.md']), addedFiles: ['docs/specs/700-new.md'] };
const edited = { ...commit('Fix typo', 'Issue: #162', ['docs/specs/162-old.md']), addedFiles: [] };
const readme = { ...commit('Archive note', 'Issue: #517', ['docs/specs/README.md']), addedFiles: ['docs/specs/README.md'] };
const unknown = commit('Spec', 'Issue: #700', ['docs/specs/700-new.md']); // addedFiles не доказаны
const out = checkFrozenSpecs([added, edited, readme, unknown]);
assert.equal(out.length, 1);
assert.equal(out[0].level, 'warn');
assert.match(out[0].msg, /docs\/specs\/700-new\.md/);
});
test('a rebase re-run may exceed the cycle limit in documents (#227)', () => {
// Документ нумеруется по заходу, бюджет §4 — по блокирующим вердиктам.
// Зелёное ревью с неудавшимся слиянием требует ещё одного захода после
// ребейза, цикла при этом не образуя: порог документов обязан быть выше
// лимита циклов, иначе гейт отказывает за то, что конвейер сам предписал.
assert.equal(REVIEW_DOC_LIMIT, 6);
const docs = (n) => Array.from({ length: n }, (_, i) => `CODE-REVIEW-225-r${i + 1}.md`);
assert.deepEqual(rules(checkReviewDocLimit(docs(5))), []);
assert.deepEqual(rules(checkReviewDocLimit(docs(6))), []);
assert.deepEqual(rules(checkReviewDocLimit(docs(7))), [7]);
// Дырка в нумерации тоже ловится: r7 в одиночку — это седьмой заход.
assert.deepEqual(rules(checkReviewDocLimit(['CODE-REVIEW-225-r7.md'])), [7]);
});
test('the review document count is per issue and per stage', () => {
// Считать надо по issue: документы разных задач не складываются, иначе
// репозиторий с историей ревью упирался бы в порог сам по себе.
assert.deepEqual(rules(checkReviewDocLimit([
'CODE-REVIEW-104-r1.md', 'CODE-REVIEW-104-r2.md',
])), []);
const manyIssues = Array.from({ length: 12 }, (_, i) => `CODE-REVIEW-${100 + i}-r1.md`);
assert.deepEqual(rules(checkReviewDocLimit(manyIssues)), []);
// А внутри одного issue порог действует.
assert.deepEqual(rules(checkReviewDocLimit(
Array.from({ length: 7 }, (_, i) => `CODE-REVIEW-104-r${i + 1}.md`),
)), [7]);
// Файл без номера захода проверку не роняет и не ломает разбор.
assert.deepEqual(rules(checkReviewDocLimit(['CODE-REVIEW-issue-068-2026-08-12.md'])), []);
});
test('spec and code reviews are counted apart (#395)', () => {
// Порог описывает бюджет заходов ОДНОГО этапа («четыре цикла плюс два
// ребейза»), а ревью ТЗ и ревью кода — два разных этапа со своими
// бюджетами (§4). Общая корзина наказывала задачу за то, что она честно
// прошла оба: ровно эта раскладка у #42 — 4 spec + 3 code — блокировала
// публикацию УЖЕ вынесенного зелёного вердикта три прогона подряд.
const real = [
'SPEC-REVIEW-42-r1.md', 'SPEC-REVIEW-42-r2.md',
'SPEC-REVIEW-42-r3.md', 'SPEC-REVIEW-42-r4.md',
'CODE-REVIEW-42-r1.md', 'CODE-REVIEW-42-r2.md', 'CODE-REVIEW-42-r5.md',
];
assert.deepEqual(rules(checkReviewDocLimit(real)), []);
// Ослабления нет: внутри вида порог прежний, и вид назван в сообщении.
const sevenCode = Array.from({ length: 7 }, (_, i) => `CODE-REVIEW-42-r${i + 1}.md`);
const failed = checkReviewDocLimit([...sevenCode, 'SPEC-REVIEW-42-r1.md']);
assert.deepEqual(rules(failed), [7]);
assert.match(failed[0].msg, /CODE-REVIEW/);
assert.ok(!failed[0].msg.includes('SPEC'), 'жалоба адресна: spec-документы не в счёте');
// И симметрично для ревью ТЗ.
const sevenSpec = Array.from({ length: 7 }, (_, i) => `SPEC-REVIEW-42-r${i + 1}.md`);
assert.match(checkReviewDocLimit(sevenSpec)[0].msg, /SPEC-REVIEW/);
});
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('stable promotion skips status recheck only for commits already published in a prerelease', () => {
const published = makeCommit({
sha: 'a'.repeat(40), subject: 'Fix shipped in beta', body: 'Issue: #123', files: ['src/a.ts'],
});
const postBeta = makeCommit({
sha: 'b'.repeat(40), subject: 'New promotion work', body: 'Issue: #130', files: ['scripts/a.mjs'],
});
const publishedShas = new Set([published.sha]);
assert.deepEqual(
commitsNeedingIssueStatus([published, postBeta], {
targetRef: 'refs/heads/main',
isPublishedPrereleaseCommit: (sha) => publishedShas.has(sha),
}).map((commit) => commit.sha),
[postBeta.sha],
);
assert.deepEqual(
rules(checkIssueStatuses(['130'], () => ({
ok: true, json: { state: 'CLOSED', labels: [] },
}))),
[8],
);
assert.deepEqual(
commitsNeedingIssueStatus([published, postBeta], {
targetRef: 'refs/heads/dev',
isPublishedPrereleaseCommit: (sha) => publishedShas.has(sha),
}).map((commit) => commit.sha),
[published.sha, postBeta.sha],
);
});
test('dev reconciliation ignores published main commits but keeps new post-merge work', () => {
const mainOnly = makeCommit({
sha: 'a'.repeat(40), subject: 'Main-only workflow fix', body: 'Issue: #85',
files: ['.github/workflows/mutation-gate.yml'],
});
const postMerge = makeCommit({
sha: 'b'.repeat(40), subject: 'New gate fix', body: 'Issue: #155',
files: ['scripts/process-gate.mjs'],
});
const commits = [mainOnly, postMerge];
const isCommitOnMain = (sha) => sha === mainOnly.sha;
const dev = commitsNeedingTargetValidation(commits, {
targetRef: 'refs/heads/dev', isCommitOnMain,
});
assert.deepEqual(dev.map((commit) => commit.sha), [postMerge.sha]);
const statusByIssue = (nn) => ({
ok: true,
json: nn === '85'
? { state: 'CLOSED', labels: [] }
: { state: 'OPEN', labels: [{ name: 'S6-in-progress' }] },
});
assert.deepEqual(
rules(checkIssueStatuses(
dev.flatMap((candidate) => candidate.issues).map((issue) => issue.slice(1)),
statusByIssue,
)),
[],
);
assert.deepEqual(rules(checkIssueStatuses(['85', '155'], statusByIssue)), [8]);
// The exemption belongs only to a dev destination. Main promotion, issue
// branches and an ordinary dev push with no main-reachable commits keep the
// complete input set.
for (const targetRef of ['refs/heads/main', 'refs/heads/issue/155-gate']) {
assert.deepEqual(
commitsNeedingTargetValidation(commits, { targetRef, isCommitOnMain }),
commits,
);
}
assert.deepEqual(
commitsNeedingTargetValidation(commits, {
targetRef: 'refs/heads/dev', isCommitOnMain: () => false,
}),
commits,
);
});
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('#385(в) the diff proof runs only for release-classified commits, by the shared predicate', () => {
const raw = [
// обычный рабочий коммит — вычислитель не зовётся
`aaaaaaaaaaaa${FS}fix: ordinary work${FS}2026-08-30T10:00:00+03:00${FS}Issue: #1\nUser-Visible: no\n${RS}`,
// бета-приёмка с Release:-трейлером — ВТОРОЙ дизъюнкт предиката: релизный
`bbbbbbbbbbbb${FS}test: accept golden${FS}2026-08-30T10:05:00+03:00${FS}Issue: #2\nUser-Visible: no\nRelease: v1.69.0-beta.5\n${RS}`,
// стабильный релиз по subject — ПЕРВЫЙ дизъюнкт
`cccccccccccc${FS}Release v1.69.0${FS}2026-08-30T10:10:00+03:00${FS}Baseline-Reviewed: yes\n${RS}`,
].join('');
const calls = [];
const spy = (sha) => { calls.push(sha); return []; };
const list = parseRecords(raw, () => ['src/a.ts'], spy);
assert.deepEqual(list.map((c) => c.isRelease), [false, true, true]);
assert.deepEqual(calls, ['bbbbbbbbbbbb', 'cccccccccccc'],
'exactly the release-classified commits pay for the diff proof — same predicate, no drift');
assert.equal(list[0].releaseSourceViolations, null,
'non-release commits keep the null "unproven" marker, as before');
assert.deepEqual(list[1].releaseSourceViolations, [],
'release-classified commits carry the computed proof');
});
test('the log record parser survives multi-line commit bodies', () => {
// Разбор по строкам ломался здесь: тело содержит пустые строки и абзацы.
// Формат несёт четыре поля (#311 добавил authorDate третьим).
const raw = [
`aaaaaaaaaaaa${FS}First subject${FS}2026-08-25T19:00:00+03:00${FS}Some prose.\n\nMore prose.\n\nIssue: #1\nUser-Visible: no\n${RS}`,
`bbbbbbbbbbbb${FS}Second subject${FS}2026-08-25T20:00:00+03:00${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.equal(list[0].authorDate, '2026-08-25T19:00:00+03:00');
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-'));
// fileURLToPath, а не URL.pathname: на Windows pathname даёт «/C:/…», и
// spawnSync прочитал бы его как «C:\C:\…» (#133). Linux CI это не ловил —
// оба варианта там совпадают.
const gate = fileURLToPath(new URL('../scripts/process-gate.mjs', import.meta.url));
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/);
// Stable promotion меняет канонические строки версии внутри исходников,
// но не несёт никакого другого продуктового diff.
git('checkout', '-q', 'dev');
git('reset', '-q', '--hard', base);
write('src/houseplan-card.ts', "const CARD_VERSION = '1.69.0-beta.5';\nexport const a = 1;\n");
write('src/houseplan-editor-runtime.ts', "const CARD_VERSION = '1.69.0-beta.5';\nexport const b = 1;\n");
write('custom_components/houseplan/const.py', 'VERSION = "1.69.0-beta.5"\nVALUE = 1\n');
commitAll('Prerelease tree\n\nIssue: #1\nUser-Visible: no');
const prerelease = git('rev-parse', 'HEAD').trim();
write('src/houseplan-card.ts', "const CARD_VERSION = '1.69.0';\nexport const a = 1;\n");
write('src/houseplan-editor-runtime.ts', "const CARD_VERSION = '1.69.0';\nexport const b = 1;\n");
write('custom_components/houseplan/const.py', 'VERSION = "1.69.0"\nVALUE = 1\n');
commitAll('Release v1.69.0\n\nRelease: v1.69.0\nUser-Visible: yes');
const stable = runGate(`${prerelease}..HEAD`);
assert.equal(stable.status, 0, stable.stdout + stable.stderr);
write('src/houseplan-card.ts', "const CARD_VERSION = '1.69.1';\nexport const a = 2;\n");
commitAll('Release v1.69.1\n\nRelease: v1.69.1\nUser-Visible: yes');
const polluted = runGate('HEAD^..HEAD');
assert.equal(polluted.status, 1, polluted.stdout + polluted.stderr);
assert.match(polluted.stdout, /FAIL п\.6/);
// --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, 3);
// Проверка 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 });
}
});
test('an issue-branch range is clamped to its own commits only when the base is stale', () => {
// #190: после обязательного ребейза remote_old..local_new втягивает историю
// dev. Сужение включается только для issue-веток и только когда база
// перестала быть предком вершины — fast-forward остаётся точным.
const deps = (ancestor, mb = 'MB') => ({
targetRef: 'refs/heads/issue/117-registryless-opening',
isAncestor: () => ancestor,
mergeBaseWithDev: () => mb,
});
assert.equal(clampIssueBranchRange('OLD..NEW', deps(false)), 'MB..NEW');
assert.equal(clampIssueBranchRange('OLD..NEW', deps(true)), 'OLD..NEW');
// Не issue-ветка — не трогаем: dev и main живут по своим правилам.
assert.equal(
clampIssueBranchRange('OLD..NEW', { ...deps(false), targetRef: 'refs/heads/dev' }),
'OLD..NEW',
);
// Без origin/dev сужать не во что — fail closed остаётся за широким диапазоном.
assert.equal(clampIssueBranchRange('OLD..NEW', deps(false, null)), 'OLD..NEW');
// Тройная точка и не-диапазон проходят насквозь.
assert.equal(clampIssueBranchRange('OLD...NEW', deps(false)), 'OLD...NEW');
assert.equal(clampIssueBranchRange('HEAD', deps(false)), 'HEAD');
});
// AC #190: опубликованная issue-ветка от старого dev -> dev ушёл вперёд ->
// обязательный rebase -> push старым диапазоном remote_old..local_new. Гейт
// обязан судить только собственные коммиты ветки, но реальное нарушение в
// post-rebase коммите — по-прежнему ловить.
test('the CLI judges a rebased issue branch by its own commits (#190)', (t) => {
const probe = spawnSync('git', ['--version'], { encoding: 'utf8' });
if (probe.status !== 0) {
t.skip('git недоступен');
return;
}
const dir = mkdtempSync(join(tmpdir(), 'hp-gate-190-'));
const gate = fileURLToPath(new URL('../scripts/process-gate.mjs', import.meta.url));
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, targetRef) => spawnSync(process.execPath,
[gate, '--repo', dir, '--range', range, '--target-ref', targetRef, '--json'],
{ encoding: 'utf8' });
const targetRef = 'refs/heads/issue/7-own-work';
try {
git('init', '-q', '-b', 'dev');
write('README.md', 'base\n');
commitAll('Base');
// Опубликованная issue-ветка от старого dev.
git('checkout', '-q', '-b', 'issue/7-own-work');
write('scripts/w.mjs', 'export const w = 1;\n');
commitAll('Own work\n\nIssue: #7\nUser-Visible: no');
const publishedTip = git('rev-parse', 'HEAD').trim();
// dev ушёл вперёд коммитом с чужим номером issue — под старым диапазоном
// он выглядел бы нарушением проверки 2.
git('checkout', '-q', 'dev');
write('src/d.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');
git('update-ref', 'refs/remotes/origin/dev', git('rev-parse', 'HEAD').trim());
// Обязательный rebase issue-ветки (сценарий возврата из конфликтного слияния).
git('checkout', '-q', 'issue/7-own-work');
git('-c', 'user.name=t', '-c', 'user.email=t@t',
'rebase', '-q', 'refs/remotes/origin/dev');
// Диапазон ровно тот, что строит pre-push: старая вершина..новая.
const clamped = JSON.parse(runGate(`${publishedTip}..HEAD`, targetRef).stdout);
assert.equal(clamped.commits, 1, JSON.stringify(clamped));
assert.equal(clamped.ok, true, JSON.stringify(clamped.findings));
// Реальное нарушение в post-rebase коммите по-прежнему блокируется.
write('scripts/broken.mjs', 'export const b = 1;\n');
commitAll('Broken work without provenance');
const held = runGate(`${publishedTip}..HEAD`, targetRef);
const report = JSON.parse(held.stdout);
assert.equal(held.status, 1, held.stdout + held.stderr);
assert.equal(report.commits, 2);
assert.equal(report.findings.some((f) => f.rule === 1), true, held.stdout);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('the CLI falls back to origin/dev when BEFORE_SHA is orphaned by a force-push (#315)', (t) => {
const probe = spawnSync('git', ['--version'], { encoding: 'utf8' });
if (probe.status !== 0) {
t.skip('git недоступен');
return;
}
const dir = mkdtempSync(join(tmpdir(), 'hp-gate-315-'));
const gate = fileURLToPath(new URL('../scripts/process-gate.mjs', import.meta.url));
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);
};
try {
git('init', '-q', '-b', 'dev');
write('README.md', 'base\n');
commitAll('Base');
git('update-ref', 'refs/remotes/origin/dev', git('rev-parse', 'HEAD').trim());
git('checkout', '-q', '-b', 'issue/7-own-work');
write('scripts/w.mjs', 'export const w = 1;\n');
commitAll('Own work\n\nIssue: #7\nUser-Visible: no');
// Push-событие после force-push: BEFORE_SHA указывает на переписанную
// вершину, которой в клоне больше нет. Раньше первый же cat-file убивал
// процесс кодом 2; теперь диапазон берётся от merge-base с origin/dev.
const r = spawnSync(process.execPath, [gate, '--repo', dir, '--github-range', '--json'], {
encoding: 'utf8',
env: {
...process.env,
EVENT_NAME: 'push',
BEFORE_SHA: 'f'.repeat(40),
BASE_SHA: '',
HEAD_SHA: git('rev-parse', 'HEAD').trim(),
DEVELOPMENT_BRANCH: 'dev',
TARGET_REF: 'refs/heads/issue/7-own-work',
},
});
assert.equal(r.status, 0, r.stdout + r.stderr);
const report = JSON.parse(r.stdout);
assert.equal(report.commits, 1, JSON.stringify(report));
assert.equal(report.ok, true, JSON.stringify(report.findings));
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('an infrastructure range is recognised by the absence of class A files (#562)', () => {
const infra = makeCommit({
sha: 'a'.repeat(40), subject: 'Tune CI', body: 'Issue: #206\nUser-Visible: no',
files: ['.github/workflows/validate.yml'],
});
const docs = makeCommit({
sha: 'b'.repeat(40), subject: 'Reword', body: '', files: ['docs/PROCESS.md'],
});
const product = makeCommit({
sha: 'c'.repeat(40), subject: 'Fix render', body: 'Issue: #150\nUser-Visible: yes',
files: ['src/a.ts', 'docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md'],
});
assert.equal(isInfrastructureRange([infra]), true);
assert.equal(isInfrastructureRange([infra, docs]), true);
// Один файл класса A лишает диапазон исключения целиком: «в основном
// инфраструктурная» не бывает, иначе продуктовая правка минует проверку.
assert.equal(isInfrastructureRange([infra, product]), false);
assert.equal(isInfrastructureRange([product]), false);
// Пустой диапазон исключением не пользуется — нечему быть инфраструктурным.
assert.equal(isInfrastructureRange([]), false);
});
test('a support-relay-only change stays on the reviewed class-B track (#43)', () => {
const relay = makeCommit({
sha: 'd'.repeat(40), subject: 'Harden private support relay',
body: 'Issue: #43\nUser-Visible: no',
files: ['scripts/support-relay/hp_relay/app.py', 'scripts/support-relay/tests/test_relay.py'],
});
assert.deepEqual([...relay.classes], ['B']);
assert.equal(isInfrastructureRange([relay]), true);
});
test('statusOptional permits the pre-S7 infra push but keeps every other rule-8 refusal (#562)', () => {
// Первый push инфраструктурного issue по #562: тип, приоритет, тема — без S*.
const infraIssue = () => ({
ok: true, json: JSON.stringify({ state: 'OPEN', labels: [
{ name: 'bug' }, { name: 'P2' }, { name: 'infra' },
] }),
});
assert.deepEqual(rules(checkIssueStatuses(['206'], infraIssue)), [8]);
assert.deepEqual(
rules(checkIssueStatuses(['206'], infraIssue, { statusOptional: true })), [],
);
// Исключение снимает ТОЛЬКО требование статуса.
const closed = () => ({ ok: true, json: JSON.stringify({ state: 'CLOSED', labels: [{ name: 'infra' }] }) });
assert.deepEqual(rules(checkIssueStatuses(['206'], closed, { statusOptional: true })), [8]);
const blocked = () => ({ ok: true, json: JSON.stringify({ state: 'OPEN', labels: [
{ name: 'infra' }, { name: 'blocked' },
] }) });
assert.deepEqual(rules(checkIssueStatuses(['206'], blocked, { statusOptional: true })), [8]);
const unreachable = () => ({ ok: false, error: 'gh: could not resolve to a Repository' });
const found = checkIssueStatuses(['206'], unreachable, { statusOptional: true });
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(['206'], garbage, { statusOptional: true })), [8]);
});
// AC1 #753: вход #562 — для задачи «до первого S-статуса», а не для задачи в
// маршруте ТЗ. В `S3-spec`/`S4-spec-review` ветка до `S5` не пушится (§11.8),
// диапазон без класса A судится, как любой другой. Решение — по каждому issue.
test('statusOptional does not cover an issue in the spec route S3/S4 (#753)', () => {
const issueWith = (...labels) => () => ({
ok: true, json: JSON.stringify({ state: 'OPEN', labels: labels.map((name) => ({ name })) }),
});
const optional = (runner) => checkIssueStatuses(['753'], runner, { statusOptional: true });
const s3 = optional(issueWith('S3-spec', 'P2'));
assert.deepEqual(rules(s3), [8]);
assert.match(s3[0].msg, /#753/);
assert.match(s3[0].msg, /S3-spec/);
assert.match(s3[0].msg, /§11\.8/);
assert.match(s3[0].msg, /#562/);
const s4 = optional(issueWith('S4-spec-review', 'track:ask'));
assert.deepEqual(rules(s4), [8]);
assert.match(s4[0].msg, /S4-spec-review/);
assert.match(s4[0].msg, /§11\.8/);
// Открыты по-прежнему: без статуса, `S1-new`/`S2-analysis` (инфраструктурные
// issue от ревьюера), рабочее множество.
assert.deepEqual(optional(issueWith('bug', 'P2', 'infra')), []);
assert.deepEqual(optional(issueWith('S1-new', 'infra')), []);
assert.deepEqual(optional(issueWith('S2-analysis')), []);
assert.deepEqual(optional(issueWith('S6-in-progress', 'track:show')), []);
// Трек без статуса маршрута ТЗ исключение не снимает.
assert.deepEqual(optional(issueWith('track:ask', 'infra')), []);
// `blocked` по-прежнему отдельной находкой.
assert.deepEqual(rules(optional(issueWith('S4-spec-review', 'blocked'))), [8, 8]);
// Без исключения текст прежний: статус и рабочее множество.
const strict = checkIssueStatuses(['753'], issueWith('S3-spec'));
assert.deepEqual(rules(strict), [8]);
assert.match(strict[0].msg, /нужен один из/);
// Два issue одного диапазона: отказ только у задачи в маршруте ТЗ.
const byNumber = {
753: issueWith('S4-spec-review', 'track:ask'),
206: issueWith('bug', 'P2', 'infra'),
};
const pair = checkIssueStatuses(['753', '206'], (nn) => byNumber[nn](), { statusOptional: true });
assert.deepEqual(rules(pair), [8]);
assert.match(pair[0].msg, /^issue #753 /);
});
// AC #562: сквозной прогон CLI по настоящему репозиторию с подставным gh.
// Диапазон без класса A и с issue без статусной метки обязан быть зелёным;
// тот же диапазон плюс один файл `src/**` — красным по проверке 8.
test('the CLI permits a pre-S7 class-B-only range but not one with class A (#562)', (t) => {
if (process.platform === 'win32') {
t.skip('нужен исполняемый stub gh — прогон в Linux CI');
return;
}
const probe = spawnSync('git', ['--version'], { encoding: 'utf8' });
if (probe.status !== 0) {
t.skip('git недоступен');
return;
}
const dir = mkdtempSync(join(tmpdir(), 'hp-gate-207-'));
const gate = fileURLToPath(new URL('../scripts/process-gate.mjs', import.meta.url));
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);
};
// Подставной gh: первый push инфраструктурного issue по #562 — без S*.
const ghStub = join(dir, 'gh-stub.mjs');
writeFileSync(ghStub, '#!/usr/bin/env node\n'
+ 'process.stdout.write(JSON.stringify({ number: 206, state: "OPEN", labels: '
+ '[{ name: "bug" }, { name: "P2" }, { name: "infra" }] }));\n', { mode: 0o755 });
const runGate = (range) => spawnSync(process.execPath,
[gate, '--repo', dir, '--range', range, '--issues'],
{ encoding: 'utf8', env: { ...process.env, GH_BIN: ghStub } });
try {
git('init', '-q', '-b', 'dev');
write('README.md', 'base\n');
commitAll('Base');
const base = git('rev-parse', 'HEAD').trim();
write('.github/workflows/validate.yml', 'name: Validate\n');
commitAll('Tune CI\n\nIssue: #206\nUser-Visible: no');
const infraOnly = runGate(`${base}..HEAD`);
assert.equal(infraOnly.status, 0, infraOnly.stdout + infraOnly.stderr);
// Пропуск виден в выводе, а не молчалив.
assert.match(infraOnly.stdout, /инфраструктурный диапазон/);
// Один продуктовый файл — и требование статуса возвращается.
write('src/a.ts', 'export const a = 1;\n');
write('docs/CHANGELOG.md', 'ru\n');
write('docs/CHANGELOG.ru.md', 'en\n');
commitAll('Fix render\n\nIssue: #206\nUser-Visible: yes');
const withProduct = runGate(`${base}..HEAD`);
assert.equal(withProduct.status, 1, withProduct.stdout + withProduct.stderr);
assert.match(withProduct.stdout, /FAIL п\.8/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
// AC2 #753: сквозной CLI. Диапазон из одного коммита `test/**` у задачи в
// `S4-spec-review` — отказ п.8; тот же диапазон у задачи в `S1-new` — вход #562.
test('the CLI refuses a class-B-only range of an issue in S3/S4 but not in S1 (#753)', (t) => {
if (process.platform === 'win32') {
t.skip('нужен исполняемый stub gh — прогон в Linux CI');
return;
}
const probe = spawnSync('git', ['--version'], { encoding: 'utf8' });
if (probe.status !== 0) {
t.skip('git недоступен');
return;
}
const dir = mkdtempSync(join(tmpdir(), 'hp-gate-753-'));
const gate = fileURLToPath(new URL('../scripts/process-gate.mjs', import.meta.url));
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;
};
// Подставной gh: статус задачи — из окружения прогона.
const ghStub = join(dir, 'gh-stub.mjs');
writeFileSync(ghStub, '#!/usr/bin/env node\n'
+ 'process.stdout.write(JSON.stringify({ number: 753, state: "OPEN", labels: '
+ '[{ name: process.env.HP_STUB_STATUS }, { name: "track:ask" }, { name: "P2" }] }));\n',
{ mode: 0o755 });
const runGate = (range, status) => spawnSync(process.execPath,
[gate, '--repo', dir, '--range', range, '--issues'],
{ encoding: 'utf8', env: { ...process.env, GH_BIN: ghStub, HP_STUB_STATUS: status } });
try {
git('init', '-q', '-b', 'dev');
writeFileSync(join(dir, 'README.md'), 'base\n');
git('add', '-A');
git('-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'core.hooksPath=/dev/null',
'commit', '-q', '-m', 'Base');
const base = git('rev-parse', 'HEAD').trim();
mkdirSync(join(dir, 'test'), { recursive: true });
writeFileSync(join(dir, 'test', 'a.test.mjs'), 'export {};\n');
git('add', '-A');
git('-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'core.hooksPath=/dev/null',
'commit', '-q', '-m', 'Add a test\n\nIssue: #753\nUser-Visible: no');
for (const status of ['S4-spec-review', 'S3-spec']) {
const refused = runGate(`${base}..HEAD`, status);
assert.equal(refused.status, 1, refused.stdout + refused.stderr);
assert.match(refused.stdout, /FAIL п\.8/);
assert.match(refused.stdout, new RegExp(`#753 в ${status}`));
// Пропуск диапазона по-прежнему виден, отказ — отдельной находкой.
assert.match(refused.stdout, /инфраструктурный диапазон \(#562\)/);
}
const entry = runGate(`${base}..HEAD`, 'S1-new');
assert.equal(entry.status, 0, entry.stdout + entry.stderr);
assert.match(entry.stdout, /инфраструктурный диапазон \(#562\)/);
assert.doesNotMatch(entry.stdout, /FAIL/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('rule 2 exempts a pipeline review document proven by subject and diff (#305)', () => {
const doc = makeCommit({
sha: 'a'.repeat(40),
subject: 'docs: review document for #304',
body: 'Issue: #304\nUser-Visible: no',
files: ['docs/reviews/CODE-REVIEW-304-r1.md'],
});
assert.equal(isPipelineReviewDocCommit(doc), true);
assert.deepEqual(checkBranchRule('issue/302-junction-node-material', [doc]), []);
// Any code beside the document voids the proof — rule 2 fires again.
const forged = makeCommit({
sha: 'b'.repeat(40),
subject: 'docs: review document for #304',
body: 'Issue: #304',
files: ['docs/reviews/CODE-REVIEW-304-r1.md', 'src/houseplan-card.ts'],
});
assert.equal(isPipelineReviewDocCommit(forged), false);
assert.equal(checkBranchRule('issue/302-junction-node-material', [forged]).length, 1);
// Fail-closed: without a file list the exemption cannot prove itself.
const blind = makeCommit({
sha: 'c'.repeat(40),
subject: 'docs: review document for #304',
body: 'Issue: #304',
files: [],
});
assert.equal(isPipelineReviewDocCommit(blind), false);
assert.equal(checkBranchRule('issue/302-junction-node-material', [blind]).length, 1);
});
test('rule 10 pins DoR to the commit author date, not to the current label (#311)', () => {
const codeAt = (iso) => makeCommit({
sha: 'e'.repeat(40), subject: 'feat: work (#266)',
body: 'Issue: #266\nUser-Visible: no',
files: ['src/houseplan-card.ts'], authorDate: iso,
});
const timeline = (events) => () => ({ ok: true, events });
const ready = [{ label: 'S5-ready', at: '2026-08-25T20:00:00Z' }];
// Written BEFORE the issue ever reached S5-ready — the violation stays
// visible no matter how far the label has advanced since.
const early = checkCommitEraStatuses([codeAt('2026-08-25T19:00:00Z')], timeline(ready));
assert.equal(early.length, 1);
assert.equal(early[0].level, 'fail');
assert.equal(early[0].rule, 10);
// Written after readiness — clean.
assert.deepEqual(
checkCommitEraStatuses([codeAt('2026-08-25T21:00:00Z')], timeline(ready)), []);
// Secondary check degrades to a warn without timeline data; rule 8 stays
// the fail-closed primary.
const blind = checkCommitEraStatuses([codeAt('2026-08-25T19:00:00Z')], () => ({ ok: false }));
assert.equal(blind.length, 1);
assert.equal(blind[0].level, 'warn');
const empty = checkCommitEraStatuses([codeAt('2026-08-25T19:00:00Z')], timeline([]));
assert.equal(empty.length, 1);
assert.equal(empty[0].level, 'warn');
// Non-code commits (spec/docs before S5) are legitimate and out of scope.
const doc = makeCommit({
sha: 'f'.repeat(40), subject: 'docs: spec for #266',
body: 'Issue: #266', files: ['docs/specs/266-x.md'], authorDate: '2026-08-25T19:00:00Z',
});
assert.deepEqual(checkCommitEraStatuses([doc], timeline(ready)), []);
});
// #738: правило 10 судит статус задачи на authorDate коммита по эпохам статуса.
// Возврат `S5+ → S3/S4` (reclassify #726, ручной) закрывает эпоху разработки:
// код, написанный в S3/S4 и запушенный после нового S5, — отказ.
const eraCode = (iso, { sha = 'a'.repeat(40), files = ['src/houseplan-card.ts'] } = {}) => makeCommit({
sha, subject: 'feat: work (#738)', body: 'Issue: #738\nUser-Visible: no', files, authorDate: iso,
});
const eraTimeline = (events) => () => ({ ok: true, events });
const H = 3600 * 1000;
const at = (base, hours) => new Date(Date.parse(base) + hours * H).toISOString();
const T1 = '2026-09-01T10:00:00.000Z'; // S5-ready
const T2 = '2026-09-02T10:00:00.000Z'; // S6-in-progress
const T3 = '2026-09-03T10:00:00.000Z'; // S7-code-review
const T4 = '2026-09-04T10:00:00.000Z'; // S3-spec — возврат
const T5 = '2026-09-05T10:00:00.000Z'; // S4-spec-review
const T6 = '2026-09-06T10:00:00.000Z'; // S5-ready — повторная готовность
const RETURN_ROUTE = [
{ label: 'S5-ready', at: T1 },
{ label: 'S6-in-progress', at: T2 },
{ label: 'S7-code-review', at: T3 },
{ label: 'S3-spec', at: T4 },
{ label: 'S4-spec-review', at: T5 },
{ label: 'S5-ready', at: T6 },
];
const era = (iso, events = RETURN_ROUTE, options) =>
checkCommitEraStatuses([eraCode(iso)], eraTimeline(events), options);
test('#738 AC1: rule 10 judges the status at authorDate — code written after a return is out of the dev era', () => {
// Written in S6, before the return — still legitimate after it.
assert.deepEqual(era(at(T2, 1)), []);
// Written in S3 after the return, pushed after the new S5 — fail naming S3-spec and t4.
const inS3 = era(at(T4, 1));
assert.equal(inS3.length, 1);
assert.equal(inS3[0].level, 'fail');
assert.equal(inS3[0].rule, 10);
assert.match(inS3[0].msg, /в S3-spec/);
assert.ok(inS3[0].msg.includes(`после возврата ${T4}`), inS3[0].msg);
assert.ok(inS3[0].msg.includes(`(${T6})`), inS3[0].msg);
// S4 is the same pre-ready era as S3: the return time stays t4.
const inS4 = era(at(T5, 1));
assert.equal(inS4.length, 1);
assert.equal(inS4[0].level, 'fail');
assert.equal(inS4[0].rule, 10);
assert.match(inS4[0].msg, /в S4-spec-review/);
assert.ok(inS4[0].msg.includes(`после возврата ${T4}`), inS4[0].msg);
assert.ok(inS4[0].msg.includes(`(${T6})`), inS4[0].msg);
// Written after the repeated readiness — clean.
assert.deepEqual(era(at(T6, 1)), []);
// Written before the first readiness — the old text.
const early = era(at(T1, -1));
assert.equal(early.length, 1);
assert.equal(early[0].level, 'fail');
assert.equal(early[0].rule, 10);
assert.match(early[0].msg, /до первого достижения задачей статуса из/);
assert.ok(early[0].msg.includes(`(${T1})`), early[0].msg);
assert.doesNotMatch(early[0].msg, /после возврата/);
});
test('#738 AC1: two returns are judged each by its own readiness; a return without a new S5 is "not reached yet"', () => {
const T7 = '2026-09-07T10:00:00.000Z'; // S6
const T8 = '2026-09-08T10:00:00.000Z'; // S3-spec — второй возврат
const T9 = '2026-09-09T10:00:00.000Z'; // S4-spec-review
const T10 = '2026-09-10T10:00:00.000Z'; // S6-in-progress — готовность без S5
const twice = [
...RETURN_ROUTE,
{ label: 'S6-in-progress', at: T7 },
{ label: 'S3-spec', at: T8 },
{ label: 'S4-spec-review', at: T9 },
{ label: 'S6-in-progress', at: T10 },
];
const first = era(at(T5, 1), twice);
assert.equal(first.length, 1);
assert.ok(first[0].msg.includes(`после возврата ${T4}`), first[0].msg);
assert.ok(first[0].msg.includes(`(${T6})`), first[0].msg);
assert.deepEqual(era(at(T7, 1), twice), []);
const second = era(at(T9, 1), twice);
assert.equal(second.length, 1);
assert.match(second[0].msg, /в S4-spec-review/);
assert.ok(second[0].msg.includes(`после возврата ${T8}`), second[0].msg);
assert.ok(second[0].msg.includes(`(${T10})`), second[0].msg);
assert.deepEqual(era(at(T10, 1), twice), []);
// Returned and never ready again.
const stuck = era(at(T4, 1), RETURN_ROUTE.slice(0, 4));
assert.equal(stuck.length, 1);
assert.equal(stuck[0].level, 'fail');
assert.ok(stuck[0].msg.includes(`после возврата ${T4}`), stuck[0].msg);
assert.match(stuck[0].msg, /ещё не достигнут/);
});
test('#738 AC2: rule 10 keeps the old verdicts on ready-only timelines and ignores non-status labels', () => {
// A timeline of `allowed` labels only gives exactly the old findings.
const readyOnly = RETURN_ROUTE.filter((e) => ALLOWED_STATUS.includes(e.label));
for (const iso of [at(T1, -1), at(T1, 1), at(T4, 1), at(T6, 1)]) {
const got = era(iso, readyOnly);
if (Date.parse(iso) < Date.parse(T1)) {
assert.deepEqual(got, [{
level: 'fail', rule: 10, sha: 'aaaaaaaa',
msg: `issue #738: коммит класса A написан ${iso}, до первого достижения задачей статуса из `
+ `${ALLOWED_STATUS.join('/')} (${T1}) — код раньше «Готово к разработке» (§12)`,
}], iso);
} else {
assert.deepEqual(got, [], iso);
}
}
// Class B and C commits in the S3 era are not rule 10's business.
for (const files of [['scripts/process-gate.mjs'], ['docs/SCOPE.md']]) {
assert.deepEqual(checkCommitEraStatuses(
[eraCode(at(T4, 1), { files })], eraTimeline(RETURN_ROUTE)), [], files[0]);
}
// `blocked` and `track:ask` between S6 and S7 change nothing.
const noisy = [
...RETURN_ROUTE.slice(0, 2),
{ label: 'blocked', at: at(T2, 2) },
{ label: 'track:ask', at: at(T2, 3) },
...RETURN_ROUTE.slice(2),
];
assert.deepEqual(era(at(T2, 4), noisy), []);
assert.equal(era(at(T4, 1), noisy).length, 1);
// With the strict set S8-merged neither opens nor closes the era.
const s8opens = [
{ label: 'S3-spec', at: T1 },
{ label: 'S8-merged', at: T2 },
{ label: 'S5-ready', at: T3 },
];
const strictEarly = era(at(T2, 1), s8opens, { allowed: STRICT_STATUS });
assert.equal(strictEarly.length, 1);
assert.match(strictEarly[0].msg, /до первого достижения задачей статуса из/);
assert.ok(strictEarly[0].msg.includes(`(${T3})`), strictEarly[0].msg);
assert.deepEqual(era(at(T2, 1), s8opens), [], 'ALLOWED_STATUS: S8-merged opens');
const s8closes = [
{ label: 'S5-ready', at: T1 },
{ label: 'S8-merged', at: T2 },
];
assert.deepEqual(era(at(T2, 1), s8closes, { allowed: STRICT_STATUS }), []);
// Unsorted events give the same result.
const shuffled = [RETURN_ROUTE[3], RETURN_ROUTE[5], RETURN_ROUTE[0], RETURN_ROUTE[4], RETURN_ROUTE[2], RETURN_ROUTE[1]];
for (const iso of [at(T1, -1), at(T2, 1), at(T4, 1), at(T5, 1), at(T6, 1)]) {
assert.deepEqual(era(iso, shuffled), era(iso), iso);
}
// authorDate equal to the S3-spec event is already in S3 (`at ≤ w`).
const onEdge = era(T4);
assert.equal(onEdge.length, 1);
assert.match(onEdge[0].msg, /в S3-spec/);
// …and equal to the repeated S5 is already ready.
assert.deepEqual(era(T6), []);
});
// #729: черновик `track:ask` во время ревью ТЗ (PROCESS.md §11.8) — одно
// исключение поверх эпох #738. Общая шкала AC1: `track:ask` и `S3-spec` 09:00,
// `S4-spec-review` 10:00, зелёный SPEC-REVIEW-729-r1 (тело H) добавлен 10:30,
// `S5-ready` 10:40, `S6-in-progress` 10:45. Коммит — класс A (`src/a.ts`).
const DAY = '2026-09-20';
const hm = (time) => `${DAY}T${time}:00.000Z`;
const BODY_H = '## ТЗ\n\n- AC1: черновик принимается';
const BH = issueBodyDigest(BODY_H);
const BH_OTHER = issueBodyDigest('## ТЗ\n\n- AC1: черновик по другому тексту');
const BH1 = issueBodyDigest('## ТЗ\n\n- AC1: раунд 1');
const BH2 = issueBodyDigest('## ТЗ\n\n- AC1: раунд 2');
const BH3 = issueBodyDigest('## ТЗ\n\n- AC1: раунд 3');
const sha256 = (hex) => `sha256:${hex}`;
const draftCode = (time, { trailers = [sha256(BH)], files = ['src/a.ts'], sha = 'd'.repeat(40) } = {}) => makeCommit({
sha, subject: 'feat: draft (#729)', files, authorDate: hm(time),
body: ['Issue: #729', 'User-Visible: no', ...trailers.map((v) => `Spec-Draft: ${v}`)].join('\n'),
});
const specDoc = (round, added, { verdict = 'green', high = 0, body = BH } = {}) => ({
name: `SPEC-REVIEW-729-r${round}.md`,
addedAt: hm(added),
text: `# SPEC-REVIEW-729-r${round}\n\n${materialAnchorBlock({ verdict, high, issueBody: body ?? undefined })}`,
});
const docsReader = (docs) => {
const reader = { head: 'HEAD', reads: 0, read: () => { reader.reads += 1; return docs; } };
return reader;
};
const ev = (label, time) => ({ label, at: hm(time) });
const DRAFT_ROUTE = [
ev('track:ask', '09:00'), ev('S3-spec', '09:00'), ev('S4-spec-review', '10:00'),
ev('S5-ready', '10:40'), ev('S6-in-progress', '10:45'),
];
const DRAFT_DOCS = [specDoc(1, '10:30')];
const drafted = (commit, { events = DRAFT_ROUTE, docs = DRAFT_DOCS, allowed } = {}) =>
checkCommitEraStatuses([commit], eraTimeline(events), {
specReviews: docsReader(docs), ...(allowed ? { allowed } : {}),
});
const failOf = (findings) => {
assert.equal(findings.length, 1, JSON.stringify(findings));
assert.equal(findings[0].level, 'fail');
assert.equal(findings[0].rule, 10);
return findings[0].msg;
};
test('#729 AC1: a draft written in S4 with the accepted body hash is accepted, others are refused', () => {
// Чем краснеет: на коде #738 этот коммит — fail «код раньше «Готово к разработке»».
assert.deepEqual(drafted(draftCode('10:10')), []);
const bare = failOf(drafted(draftCode('10:10', { trailers: [] })));
assert.match(bare, /S4-spec-review/);
assert.match(bare, /Spec-Draft/);
const stale = failOf(drafted(draftCode('10:10', { trailers: [sha256(BH_OTHER)] })));
assert.ok(stale.includes(BH_OTHER.slice(0, 12)), stale);
assert.ok(stale.includes(BH.slice(0, 12)), stale);
assert.ok(stale.includes('SPEC-REVIEW-729-r1'), stale);
assert.match(stale, /ТЗ правилось/);
// Written in the S3 era: the plain #738 text, the trailer buys nothing.
const inS3 = draftCode('09:30');
const s3 = failOf(drafted(inS3));
assert.deepEqual(drafted(inS3), checkCommitEraStatuses([inS3], eraTimeline(DRAFT_ROUTE)));
assert.match(s3, /до первого достижения задачей статуса из/);
assert.doesNotMatch(s3, /§11\.8/);
});
test('#729 AC2: the S4 epoch and its round decide — an epoch closed by S3 or S6, or still open, is refused', () => {
const rounds = [
ev('track:ask', '09:00'), ev('S3-spec', '09:00'),
ev('S4-spec-review', '10:00'), ev('S3-spec', '10:40'),
ev('S4-spec-review', '11:00'), ev('S5-ready', '11:40'),
];
const docs = [specDoc(1, '10:30', { verdict: 'yellow', body: BH1 }), specDoc(2, '11:30', { body: BH2 })];
for (const hash of [BH1, BH2]) {
const closedByS3 = failOf(drafted(draftCode('10:10', { trailers: [sha256(hash)] }), { events: rounds, docs }));
assert.match(closedByS3, /S3-spec/);
assert.ok(closedByS3.includes(hm('10:40')), closedByS3);
}
assert.deepEqual(drafted(draftCode('11:10', { trailers: [sha256(BH2)] }), { events: rounds, docs }), []);
const oldRound = failOf(drafted(draftCode('11:10', { trailers: [sha256(BH1)] }), { events: rounds, docs }));
assert.ok(oldRound.includes('SPEC-REVIEW-729-r2'), oldRound);
// A repeated S4 (reconcile #555) neither closes the epoch nor restarts it.
const repeated = [...rounds.slice(0, 5), ev('S4-spec-review', '11:20'), rounds[5]];
assert.deepEqual(drafted(draftCode('11:05', { trailers: [sha256(BH2)] }), { events: repeated, docs }), []);
const earlyDoc = [specDoc(2, '11:10', { body: BH2 })];
assert.deepEqual(drafted(draftCode('11:25', { trailers: [sha256(BH2)] }), { events: repeated, docs: earlyDoc }), []);
// The epoch is not closed yet.
const open = [ev('S5-ready', '08:00'), ev('S3-spec', '09:00'), ev('S4-spec-review', '10:00')];
assert.match(failOf(drafted(draftCode('10:10'), { events: open })), /ещё не закрыта/);
// No `allowed` event at all — the #738 warn, the exception is never reached.
const neverReady = DRAFT_ROUTE.slice(0, 3);
const blind = drafted(draftCode('10:10'), { events: neverReady });
assert.equal(blind.length, 1);
assert.equal(blind[0].level, 'warn');
assert.deepEqual(blind, checkCommitEraStatuses([draftCode('10:10')], eraTimeline(neverReady)));
// S4 → S6 without S5.
const skipped = [...DRAFT_ROUTE.slice(0, 3), ev('S6-in-progress', '10:45')];
const noS5 = failOf(drafted(draftCode('10:10'), { events: skipped }));
assert.match(noS5, /закрыта S6-in-progress/);
assert.ok(noS5.includes(hm('10:45')), noS5);
});
test('#729 AC3: the track at the author date must be ask, and the trailer format is exact', () => {
const withTrack = (label, extra = []) => [ev(label, '09:00'), ...DRAFT_ROUTE.slice(1), ...extra];
const onShow = failOf(drafted(draftCode('10:10'), { events: withTrack('track:show') }));
assert.match(onShow, /на треке show/);
assert.match(onShow, /только на `track:ask`/);
const raisedLater = withTrack('track:ask', [ev('track:show', '10:05')]);
assert.deepEqual(drafted(draftCode('10:02'), { events: raisedLater }), []);
assert.match(failOf(drafted(draftCode('10:10'), { events: raisedLater })), /на треке show/);
assert.deepEqual(drafted(draftCode('10:10'), { events: DRAFT_ROUTE.slice(1) }), [], 'no track events read as ask');
assert.match(failOf(drafted(draftCode('10:10'), { events: withTrack('small') })), /на треке show/);
for (const trailers of [
[sha256(BH.slice(0, 63))],
[sha256(BH.toUpperCase())],
[BH],
[sha256(BH), sha256(BH)],
]) {
const format = failOf(drafted(draftCode('10:10', { trailers })));
assert.match(format, /ровно один, вида `sha256:<64 hex>`/, trailers.join(' + '));
}
});
test('#729 AC4: the draft is matched with the green spec review of its own epoch', () => {
const twoEpochs = [
ev('track:ask', '09:00'), ev('S3-spec', '09:00'), ev('S4-spec-review', '10:00'),
ev('S5-ready', '10:40'), ev('S6-in-progress', '10:50'), ev('S7-code-review', '11:00'),
ev('S3-spec', '12:00'), ev('S4-spec-review', '13:00'), ev('S5-ready', '13:40'),
];
const docs = [specDoc(1, '10:30', { body: BH1 }), specDoc(3, '13:30', { body: BH3 })];
const at = (time, hash) => drafted(draftCode(time, { trailers: [sha256(hash)] }), { events: twoEpochs, docs });
assert.deepEqual(at('10:10', BH1), []);
assert.ok(failOf(at('10:10', BH3)).includes('SPEC-REVIEW-729-r1'));
assert.deepEqual(at('13:10', BH3), []);
assert.ok(failOf(at('13:10', BH1)).includes('SPEC-REVIEW-729-r3'));
for (const [why, doc] of [
['yellow', specDoc(1, '10:30', { verdict: 'yellow' })],
['High 1', specDoc(1, '10:30', { high: 1 })],
['no body anchor', specDoc(1, '10:30', { body: null })],
['added after S5', specDoc(1, '10:45')],
]) {
const missing = failOf(drafted(draftCode('10:10'), { docs: [doc] }));
assert.match(missing, /зелёный `SPEC-REVIEW-729-r\*` этой эпохи не найден ни в HEAD, ни в origin\/dev/, why);
assert.match(missing, /git fetch origin dev/, why);
assert.ok(missing.includes(`${hm('10:00')}…${hm('10:40')}`), why);
}
// Two green documents in one epoch: the higher round wins.
const twoGreen = [specDoc(1, '10:20', { body: BH }), specDoc(2, '10:35', { body: BH2 })];
assert.deepEqual(drafted(draftCode('10:10', { trailers: [sha256(BH2)] }), { docs: twoGreen }), []);
assert.ok(failOf(drafted(draftCode('10:10'), { docs: twoGreen })).includes('SPEC-REVIEW-729-r2'));
});
test('#729 AC5: without a reader rule 10 is exactly #738; class B is ignored; a trailer outside S4 only warns', () => {
const strip = (c) => ({ ...c, specDrafts: [] });
const cases = [
[draftCode('10:10'), DRAFT_ROUTE],
[draftCode('10:10', { trailers: [] }), DRAFT_ROUTE],
[draftCode('10:10', { trailers: [sha256(BH_OTHER)] }), DRAFT_ROUTE],
[draftCode('09:30'), DRAFT_ROUTE],
[draftCode('10:10', { trailers: [BH] }), DRAFT_ROUTE],
[draftCode('10:10'), [ev('S5-ready', '08:00'), ev('S3-spec', '09:00'), ev('S4-spec-review', '10:00')]],
[draftCode('10:10'), [...DRAFT_ROUTE.slice(0, 3), ev('S6-in-progress', '10:45')]],
[draftCode('10:50'), DRAFT_ROUTE],
];
for (const [c, events] of cases) {
const plain = checkCommitEraStatuses([c], eraTimeline(events));
assert.deepEqual(plain, checkCommitEraStatuses([strip(c)], eraTimeline(events)), c.authorDate);
for (const f of plain) assert.doesNotMatch(f.msg, /Spec-Draft|§11\.8/);
}
assert.deepEqual(checkCommitEraStatuses([draftCode('10:10')], eraTimeline(DRAFT_ROUTE)), [{
level: 'fail', rule: 10, sha: 'dddddddd',
msg: `issue #729: коммит класса A написан ${hm('10:10')}, до первого достижения задачей статуса из `
+ `${ALLOWED_STATUS.join('/')} (${hm('10:40')}) — код раньше «Готово к разработке» (§12)`,
}]);
// Class B with the trailer in the S4 epoch is not rule 10's business.
assert.deepEqual(drafted(draftCode('10:10', { files: ['scripts/a.mjs'], trailers: [sha256(BH_OTHER)] })), []);
// A class A commit written in S6 with the trailer: legal by #738, one warn.
const late = drafted(draftCode('10:50'));
assert.equal(late.length, 1);
assert.equal(late[0].level, 'warn');
assert.equal(late[0].rule, 10);
assert.match(late[0].msg, /написанном в S6-in-progress/);
// The strict set gives the same findings (the #738 text names the set itself).
const named = (findings, set) => findings.map((f) => ({ ...f, msg: f.msg.replace(set.join('/'), '<allowed>') }));
for (const c of [draftCode('10:10'), draftCode('10:10', { trailers: [sha256(BH_OTHER)] }), draftCode('09:30'), draftCode('10:50')]) {
assert.deepEqual(named(drafted(c, { allowed: STRICT_STATUS }), STRICT_STATUS), named(drafted(c), ALLOWED_STATUS), c.authorDate);
}
// Documents are read lazily, once per issue, only for a draft commit.
const reader = docsReader(DRAFT_DOCS);
checkCommitEraStatuses([draftCode('10:10', { trailers: [] }), draftCode('10:50')], eraTimeline(DRAFT_ROUTE), { specReviews: reader });
assert.equal(reader.reads, 0);
checkCommitEraStatuses([draftCode('10:10'), draftCode('10:20', { sha: 'e'.repeat(40) })], eraTimeline(DRAFT_ROUTE), { specReviews: reader });
assert.equal(reader.reads, 1);
});
test('#729 AC7: one hash function — the pipeline anchor and the trailer share the normalisation', () => {
const doc = { name: 'SPEC-REVIEW-729-r1.md', addedAt: hm('10:30'), text: materialAnchorBlock({ issueBody: issueBodyDigest(BODY_H), verdict: 'green', high: 0 }) };
const crlf = sha256(issueBodyDigest(`${BODY_H.replace(/\n/g, '\r\n')} \n`));
assert.deepEqual(drafted(draftCode('10:10', { trailers: [crlf] }), { docs: [doc] }), []);
// The material step still hashes the body with the same function.
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
assert.match(workflow, /m\.issueBodyDigest\(/);
});
// AC6: читатель SPEC-REVIEW из git и проводка CLI — временный репозиторий и
// подставной gh, как тест #562.
const gitMissing = (t) => {
if (spawnSync('git', ['--version'], { encoding: 'utf8' }).status === 0) return false;
t.skip('git недоступен');
return true;
};
const gitRepo = (prefix) => {
const dir = mkdtempSync(join(tmpdir(), prefix));
const git = (args, env = {}) => {
const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8', env: { ...process.env, ...env } });
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
const write = (rel, text) => {
const full = join(dir, rel);
mkdirSync(join(full, '..'), { recursive: true });
writeFileSync(full, text);
};
// Дата коммиттера намеренно другая: судится дата автора (`%aI`).
const commitAt = (time, message) => {
git(['add', '-A']);
git(['-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'core.hooksPath=/dev/null',
'commit', '-q', '-m', message], { GIT_AUTHOR_DATE: hm(time), GIT_COMMITTER_DATE: hm('23:59') });
return git(['rev-parse', 'HEAD']);
};
return { dir, git, write, commitAt };
};
const reviewDoc = (round, body = BH) => `# SPEC-REVIEW-729-r${round}\n\n${materialAnchorBlock({ verdict: 'green', high: 0, issueBody: body })}`;
test('#729 AC6: the git reader takes SPEC-REVIEW from the range head and origin/dev, one record per name', (t) => {
if (gitMissing(t)) return;
const { dir, git, write, commitAt } = gitRepo('hp-gate-729-reader-');
try {
git(['init', '-q', '-b', 'dev']);
write('README.md', 'base\n');
commitAt('08:00', 'Base');
write('docs/reviews/SPEC-REVIEW-729-r1.md', reviewDoc(1));
write('docs/reviews/SPEC-REVIEW-7290-r1.md', reviewDoc(1));
write('docs/reviews/CODE-REVIEW-729-r1.md', reviewDoc(1));
const shared = commitAt('10:30', 'docs: review document for #729');
write('docs/reviews/SPEC-REVIEW-729-r2.md', reviewDoc(2));
git(['update-ref', 'refs/remotes/origin/dev', commitAt('10:35', 'docs: review document for #729')]);
git(['checkout', '-q', '-b', 'issue/729-draft', shared]);
write('docs/reviews/SPEC-REVIEW-729-r3.md', reviewDoc(3));
commitAt('10:50', 'docs: review document for #729');
let calls = 0;
const counted = (...args) => { calls += 1; return spawnSync(...args); };
const reader = gitSpecReviewReader({ repo: dir, head: 'HEAD', run: counted });
assert.equal(calls, 0, 'nothing is read before rule 10 asks');
const docs = reader.read('729');
assert.deepEqual(docs.map((d) => d.name).sort(), ['SPEC-REVIEW-729-r1.md', 'SPEC-REVIEW-729-r2.md', 'SPEC-REVIEW-729-r3.md']);
const byName = Object.fromEntries(docs.map((d) => [d.name, d]));
assert.equal(byName['SPEC-REVIEW-729-r1.md'].ref, 'HEAD', 'a name on both refs is one record');
assert.equal(byName['SPEC-REVIEW-729-r2.md'].ref, 'refs/remotes/origin/dev');
assert.equal(byName['SPEC-REVIEW-729-r3.md'].ref, 'HEAD');
assert.equal(Date.parse(byName['SPEC-REVIEW-729-r1.md'].addedAt), Date.parse(hm('10:30')));
assert.equal(Date.parse(byName['SPEC-REVIEW-729-r2.md'].addedAt), Date.parse(hm('10:35')));
assert.equal(Date.parse(byName['SPEC-REVIEW-729-r3.md'].addedAt), Date.parse(hm('10:50')));
assert.equal(byName['SPEC-REVIEW-729-r3.md'].text, reviewDoc(3));
const after = calls;
reader.read('729');
assert.equal(calls, after, 'read once per issue');
// Without origin/dev only the head is read.
git(['update-ref', '-d', 'refs/remotes/origin/dev']);
assert.deepEqual(gitSpecReviewReader({ repo: dir, head: 'HEAD' }).read('729').map((d) => d.name).sort(),
['SPEC-REVIEW-729-r1.md', 'SPEC-REVIEW-729-r3.md']);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('#729 AC6: the CLI with --issues accepts a matching draft and refuses a stale or unproven one', (t) => {
if (process.platform === 'win32') {
t.skip('нужен исполняемый stub gh — прогон в Linux CI');
return;
}
if (gitMissing(t)) return;
const { dir, git, write, commitAt } = gitRepo('hp-gate-729-cli-');
const gate = fileURLToPath(new URL('../scripts/process-gate.mjs', import.meta.url));
const ghStub = join(dir, 'gh-stub.mjs');
const runGate = (range) => spawnSync(process.execPath, [gate, '--repo', dir, '--range', range, '--issues'],
{ encoding: 'utf8', env: { ...process.env, GH_BIN: ghStub } });
const draftMessage = (hash) => `feat: draft (#729)\n\nIssue: #729\nUser-Visible: no\nSpec-Draft: sha256:${hash}`;
try {
const timeline = DRAFT_ROUTE.map((e) => ({ label: e.label, at: e.at }));
writeFileSync(ghStub, '#!/usr/bin/env node\n'
+ 'const argv = process.argv.slice(2);\n'
+ `if (argv[0] === 'api' && /\\/issues\\/729\\/timeline$/.test(argv[1])) process.stdout.write(${JSON.stringify(JSON.stringify(timeline))} + '\\n');\n`
+ `else if (argv[0] === 'issue' && argv[1] === 'view') process.stdout.write(${JSON.stringify(JSON.stringify({
number: 729, state: 'OPEN', labels: [{ name: 'S6-in-progress' }, { name: 'track:ask' }], body: BODY_H,
}))});\n`
+ 'else { process.stderr.write(`unexpected gh ${argv.join(" ")}`); process.exit(1); }\n', { mode: 0o755 });
git(['init', '-q', '-b', 'dev']);
write('README.md', 'base\n');
const base = commitAt('08:00', 'Base');
write('docs/reviews/SPEC-REVIEW-729-r1.md', reviewDoc(1));
const withDoc = commitAt('10:30', 'docs: review document for #729');
// The rebased draft: the document below it in the range head.
write('src/a.ts', 'export const a = 1;\n');
commitAt('10:10', draftMessage(BH));
const accepted = runGate(`${base}..HEAD`);
assert.equal(accepted.status, 0, accepted.stdout + accepted.stderr);
assert.doesNotMatch(accepted.stdout, /п\.10/);
git(['reset', '-q', '--hard', withDoc]);
write('src/a.ts', 'export const a = 2;\n');
commitAt('10:10', draftMessage(BH_OTHER));
const stale = runGate(`${base}..HEAD`);
assert.equal(stale.status, 1, stale.stdout + stale.stderr);
assert.match(stale.stdout, /FAIL п\.10/);
assert.ok(stale.stdout.includes(BH_OTHER.slice(0, 12)), stale.stdout);
// The document only on origin/dev, the draft straight on the base.
git(['update-ref', 'refs/remotes/origin/dev', withDoc]);
git(['reset', '-q', '--hard', base]);
write('src/a.ts', 'export const a = 3;\n');
commitAt('10:10', draftMessage(BH));
const fromDev = runGate(`${base}..HEAD`);
assert.equal(fromDev.status, 0, fromDev.stdout + fromDev.stderr);
// On neither ref.
git(['update-ref', '-d', 'refs/remotes/origin/dev']);
const unproven = runGate(`${base}..HEAD`);
assert.equal(unproven.status, 1, unproven.stdout + unproven.stderr);
assert.match(unproven.stdout, /FAIL п\.10/);
assert.match(unproven.stdout, /git fetch origin dev/);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});