Files
houseplan-card/scripts/validate-commit-provenance.mjs
Claude 8dcc1cad4e docs(process): канон без противоречий, вход автора короче (#701)
Сверка PROCESS.md, ролевых выжимок, AGENTS.md, TESTING.md, CONTRIBUTING.md
и скриптов по 26 найденным расхождениям (D1–D26): трейлеры по классам
изменений, gate:small как единственный источник состава, пороги ревью,
путь реестра мутантов, golden по ci:golden, порядок чтения промпта ревью.

- scripts/change-classes.mjs: классы A/B/C/D — один модуль для
  process-gate и проверки трейлеров.
- commit-msg: коммит только с файлами класса C (документация) трейлеров
  не требует; указанные трейлеры по-прежнему проверяются.
- Маршрут автора без docs/STATUS.md: 5345 → 4703 слова.
- Промпт ревью читает SCOPE → AGENTS → REVIEWER, как ROUTES.reviewer.

Issue: #701
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-29 00:30:04 +03:00

237 lines
11 KiB
JavaScript
Raw Permalink 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.
#!/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';
/** 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}`);
}
}
}
// #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;
}
}