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
This commit is contained in:
Claude
2026-09-29 00:30:04 +03:00
parent 224d0106fd
commit 8dcc1cad4e
21 changed files with 232 additions and 111 deletions
+43
View File
@@ -0,0 +1,43 @@
// Классы изменений, PROCESS.md §1 — одна таблица на все гейты (#701).
//
// Вынесена из process-gate.mjs: её читает и `validate-commit-provenance.mjs`
// (хук commit-msg), которому process-gate сам импортирует — общий модуль
// снимает круговую зависимость.
// Порядок важен: D проверяется первым, иначе собранный бандл попадёт в A,
// а demo/golden/baselines — в B.
const CLASS_D = [
/^dist\//,
/^custom_components\/houseplan\/frontend\//,
/^demo\/srv\/assets\/houseplan-card\.js$/,
/^demo\/golden\/baselines\//,
];
const CLASS_A = [
/^src\//,
/^custom_components\/houseplan\/.*\.py$/,
/^hacs\.json$/,
/^custom_components\/.*\/manifest\.json$/,
/^custom_components\/.*\/translations\//,
];
const CLASS_B = [
/^test\//, /^tests_backend\//, /^demo\//, /^scripts\//,
/^\.github\//, /^\.githooks\//, /^rollup\.config\.mjs$/, /^tsconfig.*\.json$/,
/^package(-lock)?\.json$/, /^pytest\.ini$/, /^\.gitignore$/, /^\.gitattributes$/,
// Пины toolchain — производные от validate.yml (#496), конфиг сборки.
/^\.nvmrc$/, /^\.python-version$/,
];
const CLASS_C = [
/^docs\//, /^README/, /^CHANGELOG/, /^AGENTS\.md$/, /^LICENSE$/,
/^CONTRIBUTING\.md$/, /^PROCESS.*\.md$/, /^(CODE|SPEC)-REVIEW-.*\.md$/,
// #682: архив выпущенного — документы ревью и ТЗ прошлых линий. Только
// Markdown; исполняемого там нет (#678 вынес всё прочее из дерева).
/^legacy\//,
];
export function classify(path) {
if (CLASS_D.some((r) => r.test(path))) return 'D';
if (CLASS_A.some((r) => r.test(path))) return 'A';
if (CLASS_B.some((r) => r.test(path))) return 'B';
if (CLASS_C.some((r) => r.test(path))) return 'C';
return '?';
}
+4 -1
View File
@@ -25,9 +25,12 @@ const ROOT = fileURLToPath(new URL('..', import.meta.url));
/** Маршруты входа по роли. `budget: null` — только замер, без порога. */
export const ROUTES = Object.freeze({
// #701: `docs/STATUS.md` ушёл из входа автора — снимок версий и цикла нужен,
// когда сессия возобновляет работу или готовит релиз, а задачу ведёт её пакет
// (`task-packet.mjs`). Минус 691 слово на каждом входе.
author: {
budget: 12000, // AC1 #634
files: ['docs/SCOPE.md', 'AGENTS.md', 'docs/process/AUTHOR.md', 'docs/STATUS.md'],
files: ['docs/SCOPE.md', 'AGENTS.md', 'docs/process/AUTHOR.md'],
},
reviewer: {
budget: 9000,
+2 -1
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env node
// Локальный гейт лёгкого трека одной командой (#479): `npm run gate:small`.
//
// PROCESS §8 перечисляет автору шесть команд, и в #476 они гонялись
// Этот файл — единственный источник состава обязательной части §8 (#701):
// канон и конспекты его не переписывают, а называют. В #476 команды гонялись
// последовательно, вперемешку с гейтами, к задаче не относящимися. Здесь
// обязательная часть §8 начинается параллельно — сборка с typecheck, «новый
// код не добавляет any», выбор смоков по диффу. Юниты читают свежий `dist`,
+23
View File
@@ -8158,6 +8158,29 @@ const MUTANT_DEFINITIONS = [
replace: ' .hdr > .head { flex-wrap: wrap; padding: 5px 8px; gap: 6px; }',
}],
},
// #701: документационный коммит трейлеров не требует — и только он.
{
id: 'docs-only-commit-needs-trailers-again',
guard: 'node --test --test-name-pattern="#701" test/commit-provenance.test.mjs',
because: '#701 (PROCESS §3 п.10): rule #1 guards product code, not a typo in a guide; a docs-only '
+ 'commit carries no Issue/User-Visible trailers',
patches: [{
file: 'scripts/validate-commit-provenance.mjs',
find: ' const exempt = isDocsOnlyCommit(changedFiles) && !issues.length && !visible.length;',
replace: ' const exempt = false; // mutant: every commit needs trailers',
}],
},
{
id: 'docs-only-exemption-leaks-to-code',
guard: 'node --test --test-name-pattern="#701" test/commit-provenance.test.mjs',
because: '#701: one file outside class C makes the commit subject to rule #1 again; the exemption '
+ 'must not cover a commit that also touches src/**',
patches: [{
file: 'scripts/validate-commit-provenance.mjs',
find: " return changedFiles.length > 0 && changedFiles.every((file) => classify(file.replaceAll('\\\\', '/')) === 'C');",
replace: " return changedFiles.length > 0 && changedFiles.some((file) => classify(file.replaceAll('\\\\', '/')) === 'C'); // mutant",
}],
},
{
id: 'header-menu-drops-pdf',
guard: 'npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs '
+5 -37
View File
@@ -39,36 +39,11 @@ import { fileURLToPath } from 'node:url';
import { resolveValidationRange } from './validate-commit-provenance.mjs';
// --- классы изменений, PROCESS.md §1 ---
// Порядок важен: D проверяется первым, иначе собранный бандл попадёт в A,
// а demo/golden/baselines — в B.
const CLASS_D = [
/^dist\//,
/^custom_components\/houseplan\/frontend\//,
/^demo\/srv\/assets\/houseplan-card\.js$/,
/^demo\/golden\/baselines\//,
];
const CLASS_A = [
/^src\//,
/^custom_components\/houseplan\/.*\.py$/,
/^hacs\.json$/,
/^custom_components\/.*\/manifest\.json$/,
/^custom_components\/.*\/translations\//,
];
const CLASS_B = [
/^test\//, /^tests_backend\//, /^demo\//, /^scripts\//,
/^\.github\//, /^\.githooks\//, /^rollup\.config\.mjs$/, /^tsconfig.*\.json$/,
/^package(-lock)?\.json$/, /^pytest\.ini$/, /^\.gitignore$/, /^\.gitattributes$/,
// Пины toolchain — производные от validate.yml (#496), конфиг сборки.
/^\.nvmrc$/, /^\.python-version$/,
];
const CLASS_C = [
/^docs\//, /^README/, /^CHANGELOG/, /^AGENTS\.md$/, /^LICENSE$/,
/^CONTRIBUTING\.md$/, /^PROCESS.*\.md$/, /^(CODE|SPEC)-REVIEW-.*\.md$/,
// #682: архив выпущенного — документы ревью и ТЗ прошлых линий. Только
// Markdown; исполняемого там нет (#678 вынес всё прочее из дерева).
/^legacy\//,
];
// Классы изменений (PROCESS.md §1) живут в change-classes.mjs (#701): их
// читает и хук commit-msg, который судит, нужен ли коммиту трейлер.
import { classify } from './change-classes.mjs';
export { classify };
const CHANGELOGS = ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md'];
@@ -90,13 +65,6 @@ export const RULES = {
10: 'DoR по моменту коммита',
};
export function classify(path) {
if (CLASS_D.some((r) => r.test(path))) return 'D';
if (CLASS_A.some((r) => r.test(path))) return 'A';
if (CLASS_B.some((r) => r.test(path))) return 'B';
if (CLASS_C.some((r) => r.test(path))) return 'C';
return '?';
}
// --- разбор коммитов ---
// Тело коммита многострочное, поэтому поля режутся не по переводам строк:
+16 -3
View File
@@ -4,6 +4,7 @@ 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';
@@ -52,15 +53,27 @@ export function terminalTrailers(message) {
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') || [];
if (!issues.length || issues.some((value) => !/^#[1-9][0-9]*$/.test(value))) {
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");
}
const visible = trailers.get('User-Visible') || [];
if (visible.length !== 1 || !/^(yes|no)$/.test(visible[0])) {
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('\\', '/'));