ci: anchor the review material to content, not to history

#413 закрыл класс «SHA мёртв уже в момент публикации». Остаётся более частый:
SHA был жив, а умер потом — по корпусу таких объявлений 98 из 804, потому что
ветку задачи после ревью перебазируют, сквошат или удаляют.

SHA коммита — свойство истории, а история переписывается. Содержимое не
переписывается: git адресует деревья и блобы их хешем. На #403 спец-коммит
переехал из 83005c3c в 94502d3d, а блоб ТЗ у обоих один — 56a92e12; по нему
материал находится одной командой независимо от ребейза.

Конвейер снимает якоря там, где читает материал — в шаге перехода на ветку
задачи, пока рабочая копия равна тому, что прочтёт ревьюер. В шаге публикации
спрашивать поздно: дерево уже сброшено на целевую ветку. При публикации якоря
дописываются машинным блоком: дерево материала и блоб каждого ТЗ, каждый со
своей исполнимой командой поиска.

Блок машинный и помечен как машинный. Ревьюер его не заполняет: дисциплина
ручного переписывания SHA здесь уже подвела, и заменять её другой ручной
дисциплиной смысла нет.

Гейт #413 смягчён ровно там, где обязан: осиротевший SHA при живых якорях —
предупреждение, а не отказ. Ронять раунд, который воспроизводим, было бы той
же ошибкой в другую сторону. Отказ остаётся, когда не работает ни один
объявленный способ найти материал.

Проверено на настоящем осиротевшем случае: блок, собранный для 94502d3d,
находит и дерево, и блоб ТЗ; тот же документ с якорями даёт предупреждение
вместо отказа, без якорей — отказ.

Issue: #416
User-Visible: no
This commit is contained in:
Matysh
2026-09-02 08:01:15 +03:00
parent 206732e9f5
commit 6fb7bbb6ab
3 changed files with 236 additions and 10 deletions
+145 -9
View File
@@ -24,7 +24,7 @@
* нечего, значит что-то пошло не так раньше.
*/
import { spawnSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { readFileSync, writeFileSync } from 'node:fs';
export const REVIEW_DOC_ALLOWLIST = ['docs/reviews/'];
@@ -105,6 +105,20 @@ export function citedMaterialShas(text, headerLines = REVIEW_HEADER_LINES) {
return found;
}
/**
* Якоря из машинного блока: то, чем раунд воспроизводится после ребейза.
*
* Разбор нарочно грубый — ищутся сорокасимвольные хеши в блоке, а не структура.
* Блок машинный, его форма меняется вместе с этим файлом, и жёсткий парсер
* ломался бы на каждой правке формулировки.
*/
export function materialAnchorsFrom(text) {
const body = String(text ?? '');
const at = body.indexOf(ANCHOR_MARKER);
if (at < 0) return [];
return [...new Set(body.slice(at).match(/\b[0-9a-f]{40}\b/g) || [])];
}
/**
* Вердикт: `null` — все объявленные SHA существуют коммитами.
*
@@ -135,12 +149,29 @@ export function citedMaterialShas(text, headerLines = REVIEW_HEADER_LINES) {
*
* @param resolveReachable функция `(shas) => Map<sha, ref|null>`
*/
export function danglingMaterialRefusal(text, resolveReachable, headerLines = REVIEW_HEADER_LINES) {
export function danglingMaterialRefusal(
text, resolveReachable, headerLines = REVIEW_HEADER_LINES, resolveObjects = null,
) {
const cited = citedMaterialShas(text, headerLines);
if (!cited.length) return null;
const refs = resolveReachable([...new Set(cited.map((item) => item.sha))]);
const bad = cited.filter((item) => !refs.get(item.sha));
if (!bad.length) return null;
// Осиротевший SHA — ещё не потеря раунда, если якоря на месте (#414). Дерево
// и блобы адресуются содержимым: ребейз их не меняет, и материал находится
// командами из машинного блока. Отказ остаётся там, где не работает НИ ОДИН
// из объявленных способов найти материал.
const anchors = materialAnchorsFrom(text);
if (anchors.length && resolveObjects) {
const alive = anchors.filter((object) => resolveObjects(object));
if (alive.length) {
return { warning: 'SHA раунда осиротел, но материал воспроизводим по якорям:'
+ ` ${bad.map((item) => item.sha).join(', ')} недостижимы,`
+ ` якорей живых ${alive.length} из ${anchors.length}.`
+ ' Ребейз ветки после ревью — обычное дело; именно для этого якоря и'
+ ' дописываются (#414).' };
}
}
const lines = bad
.map((item) => ` строка ${item.line}: ${item.sha} → не достижим ни из одной ссылки origin`)
.join('\n');
@@ -153,10 +184,106 @@ export function danglingMaterialRefusal(text, resolveReachable, headerLines = RE
+ ' а не значения, записанного до amend или rebase.';
}
/** Маркер машинного блока: по нему блок находится и заменяется целиком. */
export const ANCHOR_MARKER = '<!-- material-anchors: сгенерировано конвейером (#414) -->';
/**
* Блок якорей материала — то, что переживает ребейз (#414).
*
* Зачем он, если SHA уже назван. SHA ветки — не свойство материала, а свойство
* истории, и история переписывается. На #403 спец-коммит переехал из
* `83005c3c` в `94502d3d` за пятнадцать минут до публикации отчёта: сообщение
* то же, содержимое то же, блоб ТЗ тот же (`56a92e12`), а команда из §2.10
* `git diff 83005c3c..HEAD` через раунд не работала. Следующий ревьюер
* восстанавливал коммит по содержимому диффа руками.
*
* Дерево и блоб адресуются содержимым, поэтому ребейз их не меняет: пока текст
* где-нибудь достижим, найти его можно одной командой. Именно эти команды и
* пишутся в блок — отчёт обязан быть исполняемым, а не описательным.
*
* Блок машинный и помечен как машинный. Ревьюер его не заполняет: дисциплина
* ручного переписывания SHA здесь уже подвела, и заменять её другой ручной
* дисциплиной смысла нет.
*/
export function materialAnchorBlock({ sha, tree, branch, specs = [] } = {}) {
const short = (value) => (typeof value === 'string' ? value.slice(0, 12) : '');
const lines = [
ANCHOR_MARKER,
'',
'## Материал раунда',
'',
`- Ветка: \`${branch || 'dev'}\`, коммит \`${short(sha)}\` — ребейз его осиротит,`
+ ' и это нормально: ниже якоря, которые ребейз не меняет.',
];
if (tree) {
lines.push(`- Дерево материала: \`${tree}\``);
lines.push(' ```');
lines.push(` git log --all --format='%H %T' | grep ${short(tree)}`);
lines.push(' ```');
}
for (const spec of specs) {
lines.push(`- ТЗ \`${spec.path}\`, блоб \`${spec.blob}\``);
lines.push(' ```');
lines.push(` git log --all --find-object=${spec.blob} -- ${spec.path}`);
lines.push(' ```');
}
if (!tree && !specs.length) {
lines.push('- Якоря снять не удалось: ветки задачи нет, материал читался по `dev`.');
}
return `${lines.join('\n')}\n`;
}
/**
* Разбор строки `--specs`: `blob путь;blob путь;`.
*
* Формат сырой намеренно: он рождается в `git ls-files -s` внутри workflow, и
* любая промежуточная сериализация здесь была бы лишним местом для ошибки.
*/
export function parseSpecList(raw) {
return String(raw ?? '')
.split(';')
.map((item) => item.trim())
.filter(Boolean)
.map((item) => {
const [blob, ...rest] = item.split(/\s+/);
return { blob, path: rest.join(' ') };
})
.filter((item) => /^[0-9a-f]{40}$/.test(item.blob) && item.path);
}
/** Дописать или заменить блок якорей в тексте документа. */
export function withMaterialAnchors(text, anchors) {
const body = String(text ?? '');
const at = body.indexOf(ANCHOR_MARKER);
const head = at >= 0 ? body.slice(0, at).replace(/\s+$/, '') : body.replace(/\s+$/, '');
return `${head}\n\n---\n\n${materialAnchorBlock(anchors)}`;
}
const invokedDirectly = process.argv[1]
&& import.meta.url === new URL(`file://${process.argv[1]}`).href;
if (invokedDirectly) {
const argv = process.argv.slice(2);
// Режим дописывания якорей (#414): конвейер снял их при чтении материала.
const anchorArg = argv.find((item) => item.startsWith('--anchor='));
if (anchorArg) {
const path = anchorArg.slice('--anchor='.length);
const value = (name) => {
const found = argv.find((item) => item.startsWith(`--${name}=`));
return found ? found.slice(name.length + 3) : '';
};
const anchors = {
sha: value('sha'),
tree: value('tree'),
branch: value('branch'),
specs: parseSpecList(value('specs')),
};
const text = readFileSync(path, 'utf8');
writeFileSync(path, withMaterialAnchors(text, anchors), 'utf8');
console.log(`якоря материала дописаны: дерево ${anchors.tree.slice(0, 12) || '—'},`
+ ` ТЗ ${anchors.specs.length}`);
process.exit(0);
}
// Режим проверки объявленного материала (#413): на входе сам документ.
const docArg = argv.find((item) => item.startsWith('--doc='));
if (docArg) {
@@ -180,16 +307,25 @@ if (invokedDirectly) {
}
return map;
};
const refusal = danglingMaterialRefusal(text, resolveReachable);
if (refusal) {
console.error(`::error::${refusal.split('\n')[0]}`);
console.error(refusal);
const resolveObjects = (object) => spawnSync('git', ['cat-file', '-e', object], {
encoding: 'utf8',
}).status === 0;
const verdict = danglingMaterialRefusal(
text, resolveReachable, REVIEW_HEADER_LINES, resolveObjects,
);
if (verdict && verdict.warning) {
console.log(`::warning::${verdict.warning}`);
} else if (verdict) {
console.error(`::error::${verdict.split('\n')[0]}`);
console.error(verdict);
process.exit(1);
}
const cited = citedMaterialShas(text);
console.log(cited.length
? `материал раунда объявлен и достижим с origin: ${cited.map((item) => item.sha).join(', ')}`
: 'материал раунда в шапке не объявлен — проверять нечего');
if (!(verdict && verdict.warning)) {
console.log(cited.length
? `материал раунда объявлен и достижим с origin: ${cited.map((item) => item.sha).join(', ')}`
: 'материал раунда в шапке не объявлен — проверять нечего');
}
process.exit(0);
}
const allowArg = argv.find((item) => item.startsWith('--allow='));