mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
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
1523 lines
76 KiB
JavaScript
1523 lines
76 KiB
JavaScript
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 });
|
||
}
|
||
});
|