Files
houseplan-card/test/pre-push-gate.test.mjs
T
Claude 0e44f8a69f feat(process): a track:ask draft may be written during the spec review (#729)
On track:ask every spec review round is 10-45 minutes of waiting, and
rule #1 kept the author idle for all of it. Rule 10 (#738) judges a
class A commit by its author date, so code written in the
S4-spec-review epoch was always refused: nothing told a draft written
against the reviewed text from a violation. The owner allowed changing
rule #1 for this (decision 2026-10-01).

A draft commit carries `Spec-Draft: sha256:<issueBodyDigest(body)>`,
the hash the pipeline already writes as "Тело issue" into the review
document anchor. Rule 10 accepts a class A commit written in S4 only
when its S4 epoch (a repeated S4 does not restart it) was closed by
S5-ready, the track at the author date was ask, and the trailer equals
the body of the green, High 0 SPEC-REVIEW added inside that epoch, read
from the range head or origin/dev. The first failing check is the one
finding: trailer format, track, how the epoch ended, the missing
document with a `git fetch origin dev` hint, or both hashes and the
document name. A trailer on a commit written in an allowed epoch is a
warn. Without the document reader rule 10 is exactly #738; main always
passes one, and it reads git only when the range holds a draft.

The task packet tells S4 on ask that a local draft is allowed while the
branch stays closed, prints the trailer line, and in S5/S6 names the
green spec review, whether the body changed since, and the --report
check before push. SPEC-REVIEW documents are a separate input
(specDocs) from the branch and origin/dev, so the previous verdict and
the AC witness keep their source.

PROCESS.md gets §11.8 and the points that refer to it (§1, §2.4-2.6,
§3 item 1, §7.2, §9, §10.2 item 10, §12); AUTHOR.md, REVIEWER.md and
AGENTS.md follow, with three new key rules in process-digests. The
pre-push hook fixture copies the modules process-gate now imports.

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

237 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
candidateRefs, gateEnv, gateMode, isDocsOrGeneratedOnly, parsePushLines, planHook, runHook,
} from '../scripts/pre-push-gate.mjs';
// #633. pre-push-gate был единственным нетестированным гейтом и включался
// только по HP_PREPUSH_GATE=1. Теперь хук сам гонит gate:small для веток issue/*
// с исполняемым диффом; эти тесты держат решение хука (чистая planHook), его
// код выхода (runHook) и настоящую связку .githooks/pre-push → gate-small на
// временном репозитории: красный набор обязан остановить push (AC1).
const A = 'a'.repeat(40);
const B = 'b'.repeat(40);
const ZERO = '0'.repeat(40);
const line = (local, sha, remote, remoteSha = ZERO) => `${local} ${sha} ${remote} ${remoteSha}`;
test('строки pre-push разбираются, пустые отбрасываются (#633)', () => {
const refs = parsePushLines(`${line('refs/heads/issue/1-x', A, 'refs/heads/issue/1-x')}\n\n \n`);
assert.deepEqual(refs, [{ localRef: 'refs/heads/issue/1-x', localSha: A, remoteRef: 'refs/heads/issue/1-x', remoteSha: ZERO }]);
assert.deepEqual(parsePushLines(''), []);
assert.deepEqual(parsePushLines(undefined), []);
});
test('HP_PREPUSH_GATE: 0 выключает, 1 форсирует, иначе — по умолчанию (#633)', () => {
for (const off of ['0', 'off', 'false', 'no', ' 0 ']) assert.equal(gateMode({ HP_PREPUSH_GATE: off }), 'off', off);
for (const on of ['1', 'on', 'true', 'yes']) assert.equal(gateMode({ HP_PREPUSH_GATE: on }), 'force', on);
assert.equal(gateMode({}), 'default');
assert.equal(gateMode({ HP_PREPUSH_GATE: '' }), 'default');
});
test('кандидаты — ветки issue/*, не теги и не удаления; force — любая ветка (#633)', () => {
const refs = parsePushLines([
line('refs/heads/issue/1-x', A, 'refs/heads/issue/1-x'),
line('refs/heads/dev', A, 'refs/heads/dev'),
line('refs/tags/v1', A, 'refs/tags/v1'),
line('(delete)', ZERO, 'refs/heads/issue/2-y', B),
].join('\n'));
assert.deepEqual(candidateRefs(refs, 'default').map((r) => r.remoteRef), ['refs/heads/issue/1-x']);
assert.deepEqual(candidateRefs(refs, 'force').map((r) => r.remoteRef), ['refs/heads/issue/1-x', 'refs/heads/dev']);
});
test('только класс C/D — «нечего проверять»; хоть один исполняемый файл — проверять (#633)', () => {
assert.equal(isDocsOrGeneratedOnly(['docs/reviews/CODE-REVIEW-1-r1.md', 'dist/houseplan-card.js']), true);
assert.equal(isDocsOrGeneratedOnly(['docs/TESTING.md', 'scripts/x.mjs']), false);
assert.equal(isDocsOrGeneratedOnly(['docs/TESTING.md', 'unknown.bin']), false, 'неизвестный класс — не «документация»');
assert.equal(isDocsOrGeneratedOnly([]), false);
});
const plan = (overrides = {}) => planHook({
mode: 'default',
refs: parsePushLines(line('refs/heads/issue/1-x', A, 'refs/heads/issue/1-x')),
headSha: A,
mergeBase: () => B,
changedFiles: () => ['scripts/x.mjs'],
...overrides,
});
test('planHook: исполняемый дифф ветки issue/* на HEAD — прогон с базой merge-base (#633)', () => {
assert.deepEqual(
{ action: plan().action, base: plan().base, ref: plan().ref },
{ action: 'run', base: B, ref: 'refs/heads/issue/1-x' },
);
});
test('planHook: пропуски названы причиной (#633)', () => {
assert.equal(plan({ mode: 'off' }).action, 'skip');
assert.match(plan({ mode: 'off' }).reason, /HP_PREPUSH_GATE=0/);
const dev = plan({ refs: parsePushLines(line('refs/heads/dev', A, 'refs/heads/dev')) });
assert.equal(dev.action, 'skip');
assert.match(dev.reason, /нет веток issue/);
const docs = plan({ changedFiles: () => ['docs/reviews/CODE-REVIEW-1-r1.md'] });
assert.equal(docs.action, 'skip');
assert.match(docs.reason, /только класс C\/D/);
assert.equal(plan({ changedFiles: () => [] }).action, 'skip');
});
test('planHook: force гонит и документацию, и не-issue ветку (#633)', () => {
assert.equal(plan({ mode: 'force', changedFiles: () => ['docs/x.md'] }).action, 'run');
assert.equal(plan({ mode: 'force', refs: parsePushLines(line('refs/heads/dev', A, 'refs/heads/dev')) }).action, 'run');
});
test('planHook: без merge-base или диффа — прогон против origin/dev, а не пропуск (#633)', () => {
const noBase = plan({ mergeBase: () => null });
assert.equal(noBase.action, 'run');
assert.equal(noBase.base, 'origin/dev');
assert.equal(plan({ changedFiles: () => null }).action, 'run');
});
test('planHook: пушится не HEAD — отказ, а не зелёный вердикт чужому дереву (#633)', () => {
const foreign = plan({ headSha: B });
assert.equal(foreign.action, 'reject');
assert.match(foreign.reason, /не HEAD/);
assert.match(foreign.reason, /HP_PREPUSH_GATE=0/);
// Не-HEAD ветка с одними документами не мешает: её нечего проверять.
assert.equal(plan({ headSha: B, changedFiles: () => ['docs/x.md'] }).action, 'skip');
});
const fakeGit = (head = A) => (args) => {
if (args[0] === 'rev-parse') return head;
if (args[0] === 'merge-base') return B;
if (args[0] === 'diff') return 'scripts/x.mjs\n';
return null;
};
test('runHook: красный gate:small — код 1, зелёный — 0, отказ не запускает набор (#633)', () => {
const stdin = line('refs/heads/issue/1-x', A, 'refs/heads/issue/1-x');
const calls = [];
const log = () => {};
assert.equal(runHook({ stdin, env: {}, log, git: fakeGit(), runGate: (base) => { calls.push(base); return 1; } }), 1);
assert.equal(runHook({ stdin, env: {}, log, git: fakeGit(), runGate: (base) => { calls.push(base); return 0; } }), 0);
assert.deepEqual(calls, [B, B]);
assert.equal(runHook({ stdin, env: {}, log, git: fakeGit(B), runGate: () => { throw new Error('не должен запускаться'); } }), 1);
assert.equal(runHook({ stdin, env: { HP_PREPUSH_GATE: '0' }, log, git: fakeGit(), runGate: () => { throw new Error('выключен'); } }), 0);
});
test('gateEnv снимает все GIT_*: набор не должен писать в репозиторий хука (#633)', () => {
const env = gateEnv({ GIT_DIR: '/repo/.git', GIT_CONFIG_PARAMETERS: "'credential.helper'='x'", git_work_tree: 'w', PATH: '/bin', HP_X: '1' });
assert.deepEqual(env, { PATH: '/bin', HP_X: '1' });
});
// ---- настоящая связка хука ------------------------------------------------
const REPO = fileURLToPath(new URL('..', import.meta.url));
// Всё, что исполняет хук: сам хук, pre-push-gate и его импорты, процессный гейт.
const HOOK_FILES = [
'.githooks/pre-push',
'scripts/pre-push-gate.mjs',
'scripts/branch-state.mjs',
'scripts/process-gate.mjs',
'scripts/change-classes.mjs', // #701: классы изменений — общие для гейта и трейлеров
'scripts/validate-commit-provenance.mjs',
'scripts/bundle-policy.mjs', // #657: правило бандла в проверке происхождения
'scripts/bundle-tree.mjs',
'scripts/spawn-portable.mjs',
// #729: правило 10 судит черновик якорями review-doc-guard и треком process-track.
'scripts/review-doc-guard.mjs',
'scripts/process-track.mjs',
'scripts/change-risk.mjs',
'scripts/review-result-gate.mjs',
];
// Заглушка набора: код выхода и журнал вызовов задаёт тест.
const GATE_STUB = `import { appendFileSync } from 'node:fs';
const leaked = Object.keys(process.env).filter((key) => /^GIT_/i.test(key)).sort().join(',');
if (process.env.HP_TEST_GATE_LOG) appendFileSync(process.env.HP_TEST_GATE_LOG, process.argv.slice(2).join(' ') + ' env:' + leaked + '\\n');
process.exit(Number(process.env.HP_TEST_GATE_EXIT ?? 0));
`;
test('настоящий .githooks/pre-push: красный gate:small останавливает push ветки issue/* (#633 AC1)', (t) => {
if (spawnSync('git', ['--version']).status !== 0) { t.skip('git недоступен'); return; }
const root = mkdtempSync(join(tmpdir(), 'hp-prepush-'));
const dir = join(root, 'work');
const origin = join(root, 'origin.git');
const gateLog = join(root, 'gate.log');
// gh без учётных данных: статус issue (п.8) проверяет CI, здесь он не нужен и
// не должен ходить в сеть.
// Свои GIT_* тест тоже не наследует: запущенный из хука (gate:small внутри
// pre-push), он иначе писал бы в репозиторий хука, а не во временный.
const env = { ...gateEnv(process.env), GH_TOKEN: '', GITHUB_TOKEN: '', GH_CONFIG_DIR: join(root, 'gh'), HP_TEST_GATE_LOG: gateLog };
delete env.HP_PREPUSH_GATE;
const git = (...args) => {
const r = spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8', env });
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
const write = (rel, text) => {
mkdirSync(join(dir, rel, '..'), { recursive: true });
writeFileSync(join(dir, rel), text);
};
const commit = (message) => {
git('add', '-A');
git('-c', 'user.name=t', '-c', 'user.email=t@t', '-c', 'core.hooksPath=/dev/null', 'commit', '-q', '-m', message);
};
const push = (branch, extraEnv = {}) => spawnSync('git',
['-C', dir, '-c', 'core.hooksPath=.githooks', 'push', '-q', 'origin', branch],
{ encoding: 'utf8', env: { ...env, ...extraEnv } });
const remoteHas = (branch) => git('ls-remote', 'origin', `refs/heads/${branch}`) !== '';
const gateCalls = () => (existsSync(gateLog) ? readFileSync(gateLog, 'utf8').split('\n').filter(Boolean) : []);
try {
mkdirSync(dir, { recursive: true });
assert.equal(spawnSync('git', ['init', '-q', '--bare', origin], { env }).status, 0);
git('init', '-q', '-b', 'dev');
for (const file of HOOK_FILES) {
mkdirSync(join(dir, file, '..'), { recursive: true });
copyFileSync(join(REPO, file), join(dir, file));
}
chmodSync(join(dir, '.githooks/pre-push'), 0o755);
write('scripts/gate-small.mjs', GATE_STUB);
commit('Base');
git('remote', 'add', 'origin', origin);
git('push', '-q', '--no-verify', 'origin', 'dev');
git('fetch', '-q', 'origin');
const base = git('rev-parse', 'HEAD');
git('checkout', '-q', '-b', 'issue/1-x');
write('scripts/x.mjs', 'export const x = 1;\n');
commit('Add x\n\nIssue: #1\nUser-Visible: no');
const red = push('issue/1-x', { HP_TEST_GATE_EXIT: '1' });
assert.notEqual(red.status, 0, `красный набор пропустил push:\n${red.stderr}`);
assert.match(red.stderr, /gate:small красный/);
assert.equal(remoteHas('issue/1-x'), false, 'ветка не должна была доехать');
// Один прогон, от merge-base с origin/dev, и ни одной переменной git: хук
// выставляет GIT_DIR, и без gateEnv юниты набора писали бы в этот репозиторий.
assert.deepEqual(gateCalls(), [`--base=${base} env:`], 'набор гонится один раз, от merge-base, без GIT_*');
const green = push('issue/1-x', { HP_TEST_GATE_EXIT: '0' });
assert.equal(green.status, 0, green.stderr);
assert.equal(remoteHas('issue/1-x'), true);
assert.equal(gateCalls().length, 2);
write('scripts/y.mjs', 'export const y = 1;\n');
commit('Add y\n\nIssue: #1\nUser-Visible: no');
const off = push('issue/1-x', { HP_TEST_GATE_EXIT: '1', HP_PREPUSH_GATE: '0' });
assert.equal(off.status, 0, `HP_PREPUSH_GATE=0 не выключил набор:\n${off.stderr}`);
assert.match(off.stderr, /HP_PREPUSH_GATE=0/);
assert.equal(gateCalls().length, 2, 'выключенный набор не запускается');
git('checkout', '-q', '-b', 'issue/2-docs', 'dev');
write('docs/reviews/CODE-REVIEW-2-r1.md', '# review\n');
commit('docs: review document for #2');
const docs = push('issue/2-docs', { HP_TEST_GATE_EXIT: '1' });
assert.equal(docs.status, 0, `дифф класса C не должен гнать набор:\n${docs.stderr}`);
assert.match(docs.stderr, /только класс C\/D/);
assert.equal(gateCalls().length, 2);
} finally {
rmSync(root, { recursive: true, force: true });
}
});