mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 15:09:30 +00:00
fix(hooks): reject a malformed or repeated Spec-Draft trailer at commit-msg (#766)
The draft trailer of #729 (PROCESS.md 11.8) was checked only by rule 10 of process-gate, on push or in CI: validateCommitMessage returned [] for `sha256:wrong`. The commit-msg hook now refuses, at commit time, a draft trailer that is repeated or not `sha256:<64 lowercase hex>`. An ordinary commit carries no such trailer and is not asked for one; the S4 epoch, the track and the green SPEC-REVIEW still need the network and history and stay with rule 10. The value format (SPEC_DRAFT_VALUE) and the parser (specDraftValues: a `Spec-Draft` line anywhere in the message, key case-insensitive) now live in validate-commit-provenance.mjs, and process-gate uses the same function for rule 10, so the hook rejects exactly what rule 10 would. Editor comment lines are ignored as before. The CI provenance job runs the same validator. Tests: wrong, short, upper-case, prefix-less and empty values and a repeat are refused, a valid draft and an ordinary commit pass; rule 10 and the hook read the same values; the real .githooks/commit-msg in a temporary repository refuses both bad commits and accepts the good ones. Checked by hand: with the check removed from validateCommitMessage all three tests turn red. Issue: #766 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
+5
-1
@@ -1096,7 +1096,9 @@ Performance зелёные на точном SHA, плюс зелёный E2E н
|
||||
трогающих `demo/golden/baselines/**`, — `Release:` плюс ровно один источник:
|
||||
`Baseline-Reviewed: <URL GitHub run>` либо
|
||||
`Baseline-Reviewed-Local: sha256:<хеш аттестации>`. Локальный хеш обязан
|
||||
совпадать с `localAttestation.sha256` в принятом индексе.
|
||||
совпадать с `localAttestation.sha256` в принятом индексе. Трейлер черновика
|
||||
`Spec-Draft`, если он есть, — ровно один, вида `sha256:<64 строчных hex>`
|
||||
(#766, §11.8); обычный коммит его не несёт.
|
||||
Реализация — `scripts/validate-commit-provenance.mjs`, тот же скрипт вызывается
|
||||
job `provenance` в `validate.yml`.
|
||||
- **`pre-push`** — есть, работает. Прогоняет `scripts/process-gate.mjs` по каждому
|
||||
@@ -1915,6 +1917,8 @@ Low в issue не пишутся. Цвет ночи — цвет Validate.
|
||||
`S5-ready`, трек на момент написания — `ask`, и трейлер равен «Тело issue»
|
||||
зелёного (High 0) `SPEC-REVIEW` этой эпохи из вершины диапазона или
|
||||
`origin/dev`. Отказ называет причину; при устаревшем ТЗ — оба хеша и документ.
|
||||
Повтор и неверный формат трейлера хук `commit-msg` отвергает раньше, при
|
||||
коммите, тем же разбором (#766, §10.1); эпоху, трек и SPEC-REVIEW он не судит.
|
||||
Комментарий «Черновик:» и учёт в WIP машиной не проверяются, как занятие `S6`.
|
||||
|
||||
Ревьюер ТЗ черновик не читает и доводом не считает: судится тело issue
|
||||
|
||||
@@ -37,7 +37,7 @@ import { existsSync, readdirSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { resolveValidationRange } from './validate-commit-provenance.mjs';
|
||||
import { SPEC_DRAFT_VALUE, resolveValidationRange, specDraftValues } from './validate-commit-provenance.mjs';
|
||||
|
||||
// Классы изменений (PROCESS.md §1) живут в change-classes.mjs (#701): их
|
||||
// читает и хук commit-msg, который судит, нужен ли коммиту трейлер.
|
||||
@@ -117,8 +117,9 @@ export function makeCommit({
|
||||
release: one('Release'),
|
||||
baselineReviewed: one('Baseline-Reviewed'),
|
||||
baselineReviewedLocal: one('Baseline-Reviewed-Local'),
|
||||
// #729: все значения, а не первое — «ровно один» судит правило 10.
|
||||
specDrafts: all('Spec-Draft'),
|
||||
// #729: все значения, а не первое — «ровно один» судит правило 10. Разбор
|
||||
// общий с хуком commit-msg (#766): тот же формат он отвергает раньше.
|
||||
specDrafts: specDraftValues(text),
|
||||
// null = вызывающий не доказал содержимое diff. Для stable release это
|
||||
// намеренно fail-closed: одного имени разрешённого version source мало.
|
||||
releaseSourceViolations,
|
||||
@@ -538,9 +539,10 @@ const iso = (ms) => new Date(ms).toISOString();
|
||||
|
||||
// #729: черновик кода во время ревью ТЗ (PROCESS.md §11.8). Статус, в котором
|
||||
// он пишется, трейлер и его значение — `issueBodyDigest` тела issue, тот же
|
||||
// хеш, что конвейер пишет в «Тело issue:» документа ревью.
|
||||
// хеш, что конвейер пишет в «Тело issue:» документа ревью. Формат значения
|
||||
// (SPEC_DRAFT_VALUE) — общий с хуком commit-msg (#766).
|
||||
export const DRAFT_STATUS = 'S4-spec-review';
|
||||
export const SPEC_DRAFT_VALUE = /^sha256:[0-9a-f]{64}$/;
|
||||
export { SPEC_DRAFT_VALUE };
|
||||
|
||||
// Трек на момент `w`: последнее событие трековой метки (`track:*`, прежние
|
||||
// `small`/`trivial`) с `at ≤ w`, трек — по одной этой метке. Событий нет —
|
||||
|
||||
@@ -29,6 +29,19 @@ const GOLDEN_PROVENANCE_ERRORS = new Set([
|
||||
const LOCAL_BASELINE = /^sha256:([0-9a-f]{64})$/;
|
||||
const BASELINE_INDEX = 'demo/golden/baselines/baselines-index.json';
|
||||
|
||||
/**
|
||||
* #729/#766: трейлер чернового коммита `track:ask` (PROCESS.md §11.8) —
|
||||
* `issueBodyDigest` тела issue. Значения читаются так же, как их читает
|
||||
* правило 10 `process-gate.mjs` (оно берёт эту функцию): строка `Spec-Draft:`
|
||||
* в любом месте сообщения, ключ без учёта регистра. Хук commit-msg отвергает
|
||||
* повтор и неверный формат сразу, а не на push; эпоху `S4`, трек и зелёный
|
||||
* SPEC-REVIEW он не судит — это сеть и история, они остаются правилу 10.
|
||||
*/
|
||||
export const SPEC_DRAFT_VALUE = /^sha256:[0-9a-f]{64}$/;
|
||||
export function specDraftValues(message) {
|
||||
return [...String(message).replace(/\r/g, '').matchAll(/^Spec-Draft:[ \t]*(.*)$/gmi)].map((m) => m[1].trim());
|
||||
}
|
||||
|
||||
/** Git invokes commit-msg before it removes the editor template. Ignore the
|
||||
* standard comment/scissors suffix exactly as Git will when it records the
|
||||
* commit, while leaving ordinary prose after trailers invalid. */
|
||||
@@ -84,6 +97,13 @@ export function validateCommitMessage(message, changedFiles = [], { baselineInde
|
||||
}
|
||||
}
|
||||
}
|
||||
// #766: черновой трейлер, если он есть, — ровно один и `sha256:<64 hex>`;
|
||||
// обычный коммит его не несёт и не обязан.
|
||||
const drafts = specDraftValues(cleanedCommitMessage(message));
|
||||
if (drafts.length > 1) errors.push(`expected at most one 'Spec-Draft' trailer, found ${drafts.length}`);
|
||||
if (drafts.some((value) => !SPEC_DRAFT_VALUE.test(value))) {
|
||||
errors.push("Spec-Draft must be 'sha256:<64 lowercase hex>'");
|
||||
}
|
||||
// #657: бандл меняет только релизный кандидат. В хуке даты нет — судится
|
||||
// всегда; в истории коммиты раньше BUNDLE_RELEASE_ONLY_SINCE не судятся.
|
||||
errors.push(...bundleCommitErrors(message, normalizedFiles, { authorDate }));
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { chmodSync, copyFileSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
SPEC_DRAFT_VALUE,
|
||||
assertHookMode,
|
||||
cleanedCommitMessage,
|
||||
resolveValidationRange,
|
||||
specDraftValues,
|
||||
terminalTrailers,
|
||||
validateHistoricalCommit,
|
||||
validateCommitMessage,
|
||||
} from '../scripts/validate-commit-provenance.mjs';
|
||||
import * as processGate from '../scripts/process-gate.mjs';
|
||||
|
||||
test('provenance accepts positive issues and one visibility trailer at the end', () => {
|
||||
const message = `Fix relay\n\nIssue: #94\nIssue: #98\nUser-Visible: yes\n`;
|
||||
@@ -121,3 +129,95 @@ test('#701: документационный коммит (только клас
|
||||
assert.equal(validateCommitMessage('docs: typo\n\nIssue: #x', ['docs/a.md']).length, 2);
|
||||
assert.deepEqual(validateCommitMessage('docs: typo\n\nIssue: #9\nUser-Visible: no', ['docs/a.md']), []);
|
||||
});
|
||||
|
||||
// #766: формат трейлера черновика `track:ask` (PROCESS.md §11.8) судит уже
|
||||
// commit-msg — тем же разбором, что правило 10 process-gate. Эпоху S4, трек и
|
||||
// SPEC-REVIEW хук не судит: это сеть, она остаётся правилу 10.
|
||||
const DRAFT_BASE = 'feat: draft (#729)\n\nIssue: #729\nUser-Visible: no';
|
||||
const DRAFT_HEX = 'a'.repeat(64);
|
||||
const FORMAT = "Spec-Draft must be 'sha256:<64 lowercase hex>'";
|
||||
|
||||
test('#766: Spec-Draft — ровно один и sha256:<64 строчных hex>; обычный коммит без него проходит', () => {
|
||||
const code = ['src/a.ts'];
|
||||
assert.deepEqual(validateCommitMessage(DRAFT_BASE, code), [], 'обычный коммит трейлера не требует');
|
||||
assert.deepEqual(validateCommitMessage(`${DRAFT_BASE}\nSpec-Draft: sha256:${DRAFT_HEX}`, code), [], 'корректный черновик');
|
||||
for (const [label, trailer] of [
|
||||
['не hex (проба аналитики)', 'Spec-Draft: sha256:wrong'],
|
||||
['63 знака', `Spec-Draft: sha256:${DRAFT_HEX.slice(1)}`],
|
||||
['заглавные', `Spec-Draft: sha256:${'A'.repeat(64)}`],
|
||||
['без префикса', `Spec-Draft: ${DRAFT_HEX}`],
|
||||
['пустое значение', 'Spec-Draft:'],
|
||||
['ключ в другом регистре — тот же трейлер', 'spec-draft: sha256:x'],
|
||||
]) assert.deepEqual(validateCommitMessage(`${DRAFT_BASE}\n${trailer}`, code), [FORMAT], label);
|
||||
assert.deepEqual(validateCommitMessage(`${DRAFT_BASE}\nSpec-Draft: sha256:${DRAFT_HEX}\nSpec-Draft: sha256:${DRAFT_HEX}`, code),
|
||||
["expected at most one 'Spec-Draft' trailer, found 2"], 'повтор, даже одинаковый');
|
||||
assert.deepEqual(validateCommitMessage(`${DRAFT_BASE}\n# Spec-Draft: sha256:wrong\n`, code), [], 'комментарий редактора — не трейлер');
|
||||
// Трейлер, если он есть, судится и в документационном коммите (#701).
|
||||
assert.deepEqual(validateCommitMessage('docs: x\n\nSpec-Draft: sha256:wrong', ['docs/a.md']), [FORMAT]);
|
||||
});
|
||||
|
||||
test('#766: commit-msg и правило 10 читают Spec-Draft одним разбором и одним форматом', () => {
|
||||
assert.equal(processGate.SPEC_DRAFT_VALUE, SPEC_DRAFT_VALUE, 'формат — один объект');
|
||||
for (const body of [
|
||||
'Issue: #729\nUser-Visible: no',
|
||||
`Issue: #729\nUser-Visible: no\nSpec-Draft: sha256:${DRAFT_HEX}`,
|
||||
'Issue: #729\nSpec-Draft: sha256:wrong\nUser-Visible: no',
|
||||
`Spec-Draft: sha256:${DRAFT_HEX}\n\nIssue: #729\nUser-Visible: no\nspec-draft: sha256:${DRAFT_HEX}`,
|
||||
'Issue: #729\nUser-Visible: no\nSpec-Draft:',
|
||||
]) {
|
||||
const drafts = processGate.makeCommit({ subject: 'feat: x', body }).specDrafts;
|
||||
assert.deepEqual(drafts, specDraftValues(body), body);
|
||||
// Правило 10 отказывает черновику при `drafts.length !== 1` или неверном значении;
|
||||
// хук — при тех же условиях, если трейлер вообще есть.
|
||||
const rule10 = drafts.length !== 1 || !SPEC_DRAFT_VALUE.test(drafts[0]);
|
||||
const hook = validateCommitMessage(`feat: x\n\n${body}`, ['src/a.ts']).some((error) => /Spec-Draft/.test(error));
|
||||
assert.equal(hook, drafts.length > 0 && rule10, body);
|
||||
}
|
||||
});
|
||||
|
||||
test('#766: настоящий .githooks/commit-msg — неверный Spec-Draft отвергнут, верный и обычный коммиты проходят', (t) => {
|
||||
if (process.platform === 'win32' || spawnSync('git', ['--version']).status !== 0) { t.skip('git/sh недоступны'); return; }
|
||||
const repo = fileURLToPath(new URL('..', import.meta.url));
|
||||
const root = mkdtempSync(join(tmpdir(), 'hp-766-msg-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
// Окружение git без GIT_* родителя и без глобального конфига (#633, #496).
|
||||
const env = {
|
||||
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))),
|
||||
GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t',
|
||||
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
|
||||
};
|
||||
const git = (...args) => spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', env });
|
||||
const ok = (r) => { assert.equal(r.status, 0, r.stderr); return r; };
|
||||
ok(git('init', '-q', '-b', 'dev'));
|
||||
// Хук и все скрипты верхнего уровня — без ручного списка импортов валидатора.
|
||||
mkdirSync(join(root, '.githooks'));
|
||||
copyFileSync(join(repo, '.githooks', 'commit-msg'), join(root, '.githooks', 'commit-msg'));
|
||||
chmodSync(join(root, '.githooks', 'commit-msg'), 0o755);
|
||||
mkdirSync(join(root, 'scripts'));
|
||||
for (const name of readdirSync(join(repo, 'scripts')).filter((file) => file.endsWith('.mjs'))) {
|
||||
copyFileSync(join(repo, 'scripts', name), join(root, 'scripts', name));
|
||||
}
|
||||
ok(git('add', '-A'));
|
||||
ok(git('update-index', '--chmod=+x', '.githooks/commit-msg'));
|
||||
ok(git('-c', 'core.hooksPath=/dev/null', 'commit', '-q', '-m', 'base\n\nIssue: #766\nUser-Visible: no'));
|
||||
let n = 0;
|
||||
const commit = (message) => {
|
||||
n += 1;
|
||||
mkdirSync(join(root, 'src'), { recursive: true });
|
||||
writeFileSync(join(root, 'src', 'a.ts'), `export const a = ${n};\n`);
|
||||
ok(git('add', 'src/a.ts'));
|
||||
writeFileSync(join(root, 'msg'), message);
|
||||
return git('-c', 'core.hooksPath=.githooks', 'commit', '-q', '-F', join(root, 'msg'));
|
||||
};
|
||||
const head = () => git('rev-parse', 'HEAD').stdout.trim();
|
||||
for (const trailer of ['Spec-Draft: sha256:wrong', `Spec-Draft: sha256:${DRAFT_HEX}\nSpec-Draft: sha256:${DRAFT_HEX}`]) {
|
||||
const before = head();
|
||||
const refused = commit(`${DRAFT_BASE}\n${trailer}\n`);
|
||||
assert.notEqual(refused.status, 0, `хук пропустил:\n${trailer}`);
|
||||
assert.match(refused.stderr, /Spec-Draft/);
|
||||
assert.equal(head(), before, 'коммит не создан');
|
||||
ok(git('reset', '-q'));
|
||||
}
|
||||
ok(commit(`${DRAFT_BASE}\nSpec-Draft: sha256:${DRAFT_HEX}\n`));
|
||||
ok(commit(`${DRAFT_BASE}\n`));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user