mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 11:18:48 +00:00
Сверка 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
237 lines
11 KiB
JavaScript
237 lines
11 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';
|
||
|
||
/** 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;
|
||
}
|
||
}
|