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('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)), []); });