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