mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
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
257 lines
12 KiB
JavaScript
257 lines
12 KiB
JavaScript
#!/usr/bin/env node
|
||
import { execFileSync } from 'node:child_process';
|
||
import { basename } from 'node:path';
|
||
import { readFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { bundleCommitErrors } from './bundle-policy.mjs';
|
||
import { classify } from './change-classes.mjs';
|
||
|
||
const TRAILER = /^([A-Za-z][A-Za-z0-9-]*):\s*(.*?)\s*$/;
|
||
export const ENFORCEMENT_BOUNDARY = '8e2973fa7a7cb1a80204ff95ecf3f2d7c36ed2ce';
|
||
|
||
// Immutable history can only be repaired with an equally immutable, exact-SHA
|
||
// audit record. This beta candidate changed one version-bearing golden frame
|
||
// before the golden trailer check reached main. The following dev commit was
|
||
// fully validated on Linux with that exact baseline tree, including a green
|
||
// golden job. Keep the exception commit-exact so no later omission is hidden.
|
||
export const REVIEWED_GOLDEN_PROVENANCE_EXCEPTIONS = new Map([
|
||
[
|
||
'd4dd027b0a27c3c290195cb0e504b1a44c4c2611',
|
||
'https://github.com/Matysh/houseplan-card/actions/runs/33760450815',
|
||
],
|
||
]);
|
||
|
||
const GOLDEN_PROVENANCE_ERRORS = new Set([
|
||
'golden baseline commit requires one Release trailer',
|
||
'golden baseline commit requires one Baseline-Reviewed trailer',
|
||
'golden baseline commit requires one Baseline-Reviewed or Baseline-Reviewed-Local trailer',
|
||
]);
|
||
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. */
|
||
export function cleanedCommitMessage(message) {
|
||
const lines = String(message).replace(/\r/g, '').split('\n');
|
||
const scissors = lines.findIndex((line) => /^\s*#\s*-+\s*>8\s*-+\s*$/.test(line));
|
||
const visible = scissors >= 0 ? lines.slice(0, scissors) : lines;
|
||
return visible.filter((line) => !/^\s*#/.test(line)).join('\n');
|
||
}
|
||
|
||
export function terminalTrailers(message) {
|
||
const lines = cleanedCommitMessage(message).split('\n');
|
||
while (lines.length && !lines.at(-1).trim()) lines.pop();
|
||
const out = new Map();
|
||
for (let index = lines.length - 1; index >= 0; index--) {
|
||
const match = lines[index].match(TRAILER);
|
||
if (!match) break;
|
||
const values = out.get(match[1]) || [];
|
||
values.unshift(match[2]);
|
||
out.set(match[1], values);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/**
|
||
* #701 (PROCESS.md §3 п.10): документационный коммит — только файлы класса C —
|
||
* трейлеров не требует, как `skip issue` у CPython. Правило №1 охраняет
|
||
* продуктовый код, а не опечатку в гайде. Пустой список файлов (сообщение без
|
||
* `--staged`) — не документационный коммит: судить нечем, правило прежнее.
|
||
* Трейлеры, если они есть, судятся всегда.
|
||
*/
|
||
export function isDocsOnlyCommit(changedFiles = []) {
|
||
return changedFiles.length > 0 && changedFiles.every((file) => classify(file.replaceAll('\\', '/')) === 'C');
|
||
}
|
||
|
||
export function validateCommitMessage(message, changedFiles = [], { baselineIndex = undefined, authorDate = null } = {}) {
|
||
const trailers = terminalTrailers(message);
|
||
const errors = [];
|
||
const issues = trailers.get('Issue') || [];
|
||
const visible = trailers.get('User-Visible') || [];
|
||
const exempt = isDocsOnlyCommit(changedFiles) && !issues.length && !visible.length;
|
||
if (!exempt && (!issues.length || issues.some((value) => !/^#[1-9][0-9]*$/.test(value)))) {
|
||
errors.push("missing or invalid terminal 'Issue: #<positive number>' trailer");
|
||
}
|
||
if (!exempt && (visible.length !== 1 || !/^(yes|no)$/.test(visible[0]))) {
|
||
errors.push("expected exactly one terminal 'User-Visible: yes|no' trailer");
|
||
}
|
||
const normalizedFiles = changedFiles.map((file) => file.replaceAll('\\', '/'));
|
||
if (visible.length === 1 && visible[0] === 'yes') {
|
||
for (const changelog of ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md']) {
|
||
if (!normalizedFiles.includes(changelog)) {
|
||
errors.push(`user-visible commit must update ${changelog}`);
|
||
}
|
||
}
|
||
}
|
||
// #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 }));
|
||
const changesGolden = changedFiles.some((file) =>
|
||
/^demo\/golden\/baselines\/.*\.(png|json)$/.test(file.replaceAll('\\', '/')));
|
||
if (changesGolden) {
|
||
const release = trailers.get('Release') || [];
|
||
const reviewed = trailers.get('Baseline-Reviewed') || [];
|
||
const reviewedLocal = trailers.get('Baseline-Reviewed-Local') || [];
|
||
if (release.length !== 1 || !release[0]) errors.push('golden baseline commit requires one Release trailer');
|
||
const sources = Number(reviewed.length === 1 && !!reviewed[0])
|
||
+ Number(reviewedLocal.length === 1 && !!reviewedLocal[0]);
|
||
if (sources !== 1 || reviewed.length > 1 || reviewedLocal.length > 1) {
|
||
errors.push('golden baseline commit requires one Baseline-Reviewed or Baseline-Reviewed-Local trailer');
|
||
}
|
||
if (reviewedLocal.length === 1) {
|
||
const digest = reviewedLocal[0].match(LOCAL_BASELINE)?.[1] || null;
|
||
if (!digest) {
|
||
errors.push("Baseline-Reviewed-Local must be 'sha256:<64 lowercase hex>'");
|
||
} else if (baselineIndex !== undefined) {
|
||
try {
|
||
const index = typeof baselineIndex === 'string' ? JSON.parse(baselineIndex) : baselineIndex;
|
||
if (index?.localAttestation?.sha256 !== digest) {
|
||
errors.push('Baseline-Reviewed-Local does not match baselines-index.json localAttestation.sha256');
|
||
}
|
||
} catch {
|
||
errors.push('Baseline-Reviewed-Local requires a readable baselines-index.json');
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return errors;
|
||
}
|
||
|
||
export function validateHistoricalCommit(commit, message, changedFiles = [], options = {}) {
|
||
const errors = validateCommitMessage(message, changedFiles, options);
|
||
if (!REVIEWED_GOLDEN_PROVENANCE_EXCEPTIONS.has(commit)) return errors;
|
||
return errors.filter((error) => !GOLDEN_PROVENANCE_ERRORS.has(error));
|
||
}
|
||
|
||
function git(args) {
|
||
return execFileSync('git', args, { encoding: 'utf8' }).trim();
|
||
}
|
||
|
||
export function assertHookMode(row = git(['ls-files', '-s', '.githooks/commit-msg'])) {
|
||
if (!row.startsWith('100755 ')) {
|
||
throw new Error('.githooks/commit-msg must be tracked as executable (100755)');
|
||
}
|
||
}
|
||
|
||
function gitObjectExists(revision, runner = git) {
|
||
try { runner(['cat-file', '-e', `${revision}^{commit}`]); return true; }
|
||
catch { return false; }
|
||
}
|
||
|
||
export function resolveValidationRange({
|
||
eventName, beforeSha, baseSha, headSha, developmentBranch = 'dev',
|
||
}, runner = git) {
|
||
if (!headSha) throw new Error('HEAD_SHA is required');
|
||
if (eventName === 'pull_request') {
|
||
if (!baseSha) throw new Error('BASE_SHA is required for a pull request');
|
||
return `${runner(['merge-base', baseSha, headSha])}..${headSha}`;
|
||
}
|
||
const hasBefore = !!beforeSha && !/^0+$/.test(beforeSha)
|
||
&& gitObjectExists(beforeSha, runner);
|
||
const comparison = hasBefore
|
||
? beforeSha
|
||
// A first push has an all-zero `before`. Issue branches are cut from the
|
||
// integration branch, not GitHub's default branch (`main`), so comparing
|
||
// with main would pull already-landed dev commits into the validation
|
||
// range and judge unrelated/closed issues again (#165).
|
||
: `refs/remotes/origin/${developmentBranch || 'dev'}`;
|
||
return `${runner(['merge-base', comparison, headSha])}..${headSha}`;
|
||
}
|
||
|
||
function assertDescendsFromBoundary(commit) {
|
||
try { git(['merge-base', '--is-ancestor', ENFORCEMENT_BOUNDARY, commit]); }
|
||
catch {
|
||
throw new Error(
|
||
`${commit} does not descend from provenance boundary ${ENFORCEMENT_BOUNDARY}`,
|
||
);
|
||
}
|
||
}
|
||
|
||
function validateOne(label, message, files, options = {}) {
|
||
const errors = validateCommitMessage(message, files, options);
|
||
if (errors.length) throw new Error(`${label}:\n- ${errors.join('\n- ')}`);
|
||
}
|
||
|
||
function main(argv) {
|
||
if (argv.includes('--check-hook-mode')) assertHookMode();
|
||
const fileAt = argv.indexOf('--message-file');
|
||
if (fileAt >= 0) {
|
||
const messageFile = argv[fileAt + 1];
|
||
if (!messageFile) throw new Error('--message-file requires a path');
|
||
if (basename(messageFile) === 'MERGE_MSG') return;
|
||
const files = argv.includes('--staged')
|
||
? git(['diff', '--cached', '--name-only', '--diff-filter=ACMR']).split('\n').filter(Boolean)
|
||
: [];
|
||
let baselineIndex;
|
||
if (files.some((file) => /^demo\/golden\/baselines\/.*\.(png|json)$/.test(file))) {
|
||
try { baselineIndex = git(['show', `:${BASELINE_INDEX}`]); } catch { baselineIndex = null; }
|
||
}
|
||
validateOne('commit message', readFileSync(messageFile, 'utf8'), files, { baselineIndex });
|
||
}
|
||
const rangeAt = argv.indexOf('--range');
|
||
const githubRange = argv.includes('--github-range');
|
||
if (rangeAt >= 0 || githubRange) {
|
||
const range = githubRange
|
||
? resolveValidationRange({
|
||
eventName: process.env.EVENT_NAME,
|
||
beforeSha: process.env.BEFORE_SHA,
|
||
baseSha: process.env.BASE_SHA,
|
||
headSha: process.env.HEAD_SHA,
|
||
developmentBranch: process.env.DEVELOPMENT_BRANCH,
|
||
})
|
||
: argv[rangeAt + 1];
|
||
if (!range) throw new Error('--range requires a git revision range');
|
||
const head = range.split('..').at(-1);
|
||
assertDescendsFromBoundary(head);
|
||
const commits = git(['rev-list', '--reverse', range]).split('\n').filter(Boolean);
|
||
for (const commit of commits) {
|
||
// The immutable introducing commit is the boundary. File presence is
|
||
// intentionally irrelevant: deleting the validator inside the range
|
||
// must not create a two-commit bypass.
|
||
try { git(['merge-base', '--is-ancestor', ENFORCEMENT_BOUNDARY, commit]); }
|
||
catch { continue; }
|
||
const parentCount = Number(git(['rev-list', '--parents', '-n', '1', commit]).split(/\s+/).length) - 1;
|
||
if (parentCount > 1) continue;
|
||
const message = git(['show', '-s', '--format=%B', commit]);
|
||
const authorDate = git(['show', '-s', '--format=%aI', commit]);
|
||
const files = git(['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', commit])
|
||
.split('\n').filter(Boolean);
|
||
let baselineIndex;
|
||
if (files.some((file) => /^demo\/golden\/baselines\/.*\.(png|json)$/.test(file))) {
|
||
try { baselineIndex = git(['show', `${commit}:${BASELINE_INDEX}`]); } catch { baselineIndex = null; }
|
||
}
|
||
const errors = validateHistoricalCommit(commit, message, files, { baselineIndex, authorDate });
|
||
if (errors.length) throw new Error(`${commit}:\n- ${errors.join('\n- ')}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||
try { main(process.argv.slice(2)); }
|
||
catch (error) {
|
||
console.error(`House Plan provenance: ${error.message}`);
|
||
process.exitCode = 1;
|
||
}
|
||
}
|