mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 12:49:56 +00:00
Rule 10 compared a class A commit's authorDate with the FIRST time the issue reached S5-ready. After a return S5+ -> S3/S4 (the #726 reclassify route or a manual return) code written in S3/S4 and pushed after the new S5 passed both rules: rule 8 saw the current S5/S6, rule 10 saw the old S5 from before the return. checkCommitEraStatuses now builds status epochs from the labeled events: a label from `allowed` opens the "may touch code" epoch, S1-new.. S4-spec-review close it, every other label (blocked, track:*, review-4, S8-merged under --no-merged) changes nothing. The status at authorDate is the last status event at or before it; a pre-ready status (or no status event at all) is a rule 10 fail. Before the first readiness the old text stays; after a return the finding names the status, the return time and the next readiness or "not reached yet". Commits written before the return stay legitimate. The timeline runner, the warn without timeline or without `allowed` events and the commit selection are unchanged. PROCESS.md §10.2 gets item 10 describing the epochs. Issue: #738 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
1061 lines
51 KiB
JavaScript
1061 lines
51 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { spawnSync } from 'node:child_process';
|
||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||
import { tmpdir } from 'node:os';
|
||
import { join } from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
import {
|
||
ALLOWED_STATUS,
|
||
STRICT_STATUS,
|
||
buildReport,
|
||
checkBranchRule,
|
||
checkCommitEraStatuses,
|
||
isPipelineReviewDocCommit,
|
||
checkIssueStatuses,
|
||
checkReviewDocLimit,
|
||
REVIEW_DOC_LIMIT,
|
||
checkFrozenSpecs,
|
||
checkSpecs,
|
||
clampIssueBranchRange,
|
||
classify,
|
||
commitsNeedingIssueStatus,
|
||
commitsNeedingTargetValidation,
|
||
commitsUnderRuleOne,
|
||
evaluateCommit,
|
||
isInfrastructureRange,
|
||
isReleaseVersionOnlyChange,
|
||
makeCommit,
|
||
parseRecords,
|
||
FS,
|
||
RS,
|
||
} from '../scripts/process-gate.mjs';
|
||
|
||
const commit = (subject, body, files) => makeCommit({ sha: 'deadbeefcafe', subject, body, files });
|
||
const rules = (findings) => findings.filter((f) => f.level === 'fail').map((f) => f.rule);
|
||
|
||
test('paths classify into A/B/C/D with the generated tree winning over source', () => {
|
||
assert.equal(classify('src/houseplan-card.ts'), 'A');
|
||
assert.equal(classify('custom_components/houseplan/api.py'), 'A');
|
||
// Собранный бандл лежит внутри custom_components — класс D должен победить.
|
||
assert.equal(classify('custom_components/houseplan/frontend/houseplan-card.js'), 'D');
|
||
assert.equal(classify('demo/golden/baselines/view.png'), 'D');
|
||
assert.equal(classify('dist/houseplan-card.js'), 'D');
|
||
assert.equal(classify('test/canvas.test.mjs'), 'B');
|
||
assert.equal(classify('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]);
|
||
});
|
||
|
||
// 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 });
|
||
}
|
||
});
|
||
|
||
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), []);
|
||
});
|