Files
Claude 696f5a789f docs(hygiene): сократить вход агента, у правила — один дом (#680)
Волна 3 эпика #674. AGENTS.md 650 → 187 строк: карта пакета, маршрут чтения,
правило №1, классы и треки одной строкой со ссылками, трейлеры, рабочие
деревья, хендофф и ожидание вердикта; пересказы PROCESS.md — ссылками на
разделы. Неверный список «Gate jobs» снят (списки jobs не копируются в прозу,
шапка PROCESS.md). Правила, жившие только в AGENTS, получили дом: жёлтый
вердикт при выполненных AC — PROCESS §2.7; свежесть бандла, съёмка только в
Linux (#455, HP_ALLOW_FOREIGN_CAPTURE) и смоки из AC до S7 (#151) —
TESTING.md; причуда демо-стенда и среда-зависимый smoke_opening_measure —
DEVELOPMENT › Smoke tests; отказ публикации без `Release:` и при несвежем
отпечатке бандла, отмена Validate новым пушем, кандидат беты не
promotion-only, fail-closed реестра Labs — DEVELOPMENT; предупреждение и
ошибка свежести скриншотов — CONTRIBUTING.

PROCESS.md: §13 (внедрение с открытым ⏳), §14 (блок со ссылкой на
несуществующий docs/PROCESS.md) и §7.3 (история) удалены. Ссылки «§7.2» на
правило полного разбора после ребейза ведут в §2.10, на сверку SHA перед
выводом — в §2.7; то же в сообщениях scripts/branch-state.mjs,
merge-candidate.mjs, review-doc-guard.mjs, pre-push-gate.mjs, в промпте
_process.yml и TESTING.md. Число `any` в прозе → `node scripts/no-new-any.mjs
--total` (новый режим, юнит-тест; было «1034 в 49 файлах», сейчас 862 в 52),
дата-число замороженного списка якорей монолита снято. Устаревшая команда
пересъёмки скриншотов в §8 заменена ссылкой на действующий путь.

STATUS.md 113 → 61 строка: сгенерированный снимок, текущий цикл и девять
строк решений; Workflow, CI, Toolchain, Tests, Scope, open items и политика
документации — ссылками (PROCESS §2.6, DEVELOPMENT › Release, TESTING);
локали en/ru/de/fr; закрытые «coverage, mypy strict» сняты.

DEVELOPMENT.md: file-sync и «Reproducible scripts» (прототип) удалены;
раздел Release — единственный дом релизной механики: введение, правила
тела стабильного релиза (#328, release:notes), шаг continuity:screencast,
источники версии по release-contract. CONTRIBUTING: ссылка на Release вместо
пересказа, замеры клона без чисел. TESTING: any-гейт — ссылкой на PROCESS §8.

entry-cost: автор 11 125 → 5 407 слов, ревьюер 8 464 → 4 285.

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

328 lines
24 KiB
JavaScript
Executable File
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
// Слияние точного кандидата (#492 §4).
//
// До этой задачи шаг «Слить ветку в dev» ревью-конвейера после зелёного
// вердикта делал `git rebase origin/dev` и сразу `push HEAD:dev`. Если `dev`
// продвинулся за время ревью (28 августа — четыре раза за день), в `dev`
// уезжало дерево, которого не видел никто: ни ревью (материал другой), ни
// Validate (на этот SHA он не бежал). Чистый ребейз не доказывает
// совместимость: соседняя правка в `dev` меняет поведение без единого
// конфликта (эксперимент аудита: 20 → 40).
//
// Правило теперь: в `dev` попадает только SHA, для которого есть зелёный
// Validate, и попадает атомарно — `--force-with-lease` на ту вершину `dev`,
// поверх которой кандидат собран. Движение `dev` во время проверки не
// перезаписывает чужое: lease отклоняется, кандидат собирается заново, не
// более трёх раз. Дифф, изменившийся при ребейзе (patch-id), — не предмет
// этого шага: вердикт к нему не применим, задача возвращается на ревью.
//
// Решение (`decideMerge`) отделено от git и gh (`ops`), чтобы таблица
// случаев §8.4 была юнит-тестом, а не верой в shell.
import { spawnSync } from 'node:child_process';
import { appendFileSync } from 'node:fs';
import { isMainModule } from './spawn-portable.mjs';
import { fileURLToPath } from 'node:url';
import {
CI_PROOF_POLICIES, evaluateCiProof, githubCandidateTree, loadGithubProofContext,
} from './ci-proof.mjs';
import { rebaseRegenerating } from './rebase-generated.mjs';
// Личность конвейера — одна на ребейз и на коммит индекса (#643).
import { CONVEYOR_IDENTITY } from './reviews-index.mjs';
/** Скрипт индекса — по абсолютному пути: слияние работает из чужого cwd (worktree кандидата). */
const REVIEWS_INDEX_SCRIPT = fileURLToPath(new URL('./reviews-index.mjs', import.meta.url));
export const MAX_ATTEMPTS = 3;
export const VALIDATE_APPEAR_MS = 3 * 60 * 1000;
export const VALIDATE_TOTAL_MS = 45 * 60 * 1000;
/**
* Чистое решение по состоянию одной попытки. Возвращает действие и, где
* применимо, статусную метку, к которой ведёт это действие.
*
* @param {object} s
* @param {boolean} s.fresh вершина ветки = материал (+ документ ревью), #312
* @param {boolean} s.devMoved dev не равен базе материала
* @param {boolean} s.conflict ребейз на dev упал
* @param {boolean} s.patchIdEqual дифф после ребейза совпадает с проверенным
* @param {'green'|'failed'|'missing'|'pending'|'cancelled'|'stale'|null} s.validate результат общего CI proof
* @param {boolean} s.leaseRejected push в dev отклонён: dev двинулся снова
* @param {number} s.attempt номер попытки, с 1
*/
export function decideMerge(s) {
if (!s.fresh) return { action: 'reject-stale', to: 'S6-in-progress' };
if (s.conflict) return { action: 'conflict', to: 'S6-in-progress' };
if (!s.devMoved) {
if (s.leaseRejected) return { action: 'retry' };
return { action: 'fast-forward', to: 'S8-merged' };
}
if (!s.patchIdEqual) return { action: 'rereview', to: 'S7-code-review' };
if (s.validate === null || s.validate === undefined) return { action: 'validate' };
if (['missing', 'pending', 'cancelled', 'stale'].includes(s.validate)) return { action: 'validation-missing', to: 'S6-in-progress' };
if (s.validate === 'failed') return { action: 'validation-red', to: 'S6-in-progress' };
if (s.leaseRejected) {
if ((s.attempt ?? 1) >= (s.maxAttempts ?? MAX_ATTEMPTS)) return { action: 'give-up', to: 'S6-in-progress' };
return { action: 'retry' };
}
return { action: 'push', to: 'S8-merged' };
}
/** Тексты комментариев в issue — один на исход. */
export function commentFor(action, ctx) {
const short = (sha) => String(sha || '').slice(0, 8);
switch (action) {
case 'reject-stale':
return `**Слияние отменено: ветка изменилась после проверенного материала (#312).**\n\n`
+ `Ревью выполнялось на \`${short(ctx.material)}\`, а вершина ветки сейчас \`${short(ctx.actual)}\` — в ней есть коммиты, которых вердикт не покрывает. Зелёный вердикт остаётся в силе только для проверенного SHA.\n\n`
+ `Задача переведена в \`S6-in-progress\`. Дальше: убедиться, что вершина ветки — именно то, что должно ехать в dev, и вернуть метку \`S7-code-review\`. Если вершина отличается от проверенного материала только коммитами публикации документов ревью, новый заход применит зелёный вердикт повторно без вызова модели (#499).`;
case 'conflict':
return `**Код-ревью зелёное — вердикт выше в силе, переделывать работу не нужно.** Не удалось только слияние: ветка \`${ctx.branch}\` конфликтует с \`dev\`.\n\n`
+ `Задача переведена в \`S6-in-progress\`, потому что работа вернулась к автору. Осталась не правка кода, а ребейз:\n\n`
+ `1. \`git fetch origin\`, затем \`git rebase origin/dev\` в ветке задачи, разрешить конфликт;\n2. запушить ветку;\n3. вернуть метку \`S7-code-review\`.\n\n`
+ `Повторный прогон ревью — не формальность: после ребейза на новый \`dev\` это другой код, и принимать его без проверки нельзя. Цикл считается по этапу, лимит на код-ревью тратится отдельно от ревью ТЗ.`;
case 'rereview':
return `**Дифф изменился при ребейзе на \`dev@${short(ctx.devNow)}\` — вердикт к нему не применим (§2.10, #492).**\n\n`
+ `Материал ревью \`${short(ctx.material)}\` и кандидат \`${short(ctx.candidate)}\` дают разные patch-id: соседние правки в \`dev\` изменили содержимое патча. Кандидат опубликован в ветку; задача возвращена в \`S7-code-review\` — новый заход ревью читает актуальный код.`;
case 'validation-red':
return `**Кандидат после ребейза на \`dev@${short(ctx.devNow)}\` красный (#492).**\n\n`
+ `Вердикт ревью на \`${short(ctx.material)}\` в силе, но точный кандидат \`${short(ctx.candidate)}\` не прошёл Validate: ${ctx.runUrl || 'прогон не найден'}. Задача переведена в \`S6-in-progress\`: разобраться с прогоном на ветке, затем вернуть \`S7-code-review\`.`;
case 'validation-missing':
return `**Validate на кандидате \`${short(ctx.candidate)}\` не появился за ${Math.round(VALIDATE_APPEAR_MS / 60000)} мин (#492).**\n\n`
+ `Кандидат опубликован в ветку, но прогон не стартовал — проверьте токен конвейера и очередь Actions. Слияние без проверки не выполняется; задача в \`S6-in-progress\`, после зелёного Validate на этом SHA вернуть \`S7-code-review\`.`;
case 'give-up':
return `**\`dev\` движется быстрее слияния: ${ctx.attempt} попытки собрать и проверить кандидата, каждый раз \`dev\` уходил до push (#492).**\n\n`
+ `Последний проверенный кандидат \`${short(ctx.candidate)}\` опубликован в ветку. Задача в \`S6-in-progress\`; вернуть \`S7-code-review\`, когда \`dev\` успокоится.`;
case 'push':
case 'fast-forward':
return `материал \`${short(ctx.material)}\` · dev@\`${short(ctx.devNow)}\` → кандидат \`${short(ctx.candidate)}\``
+ (action === 'push' ? ` · Validate ${ctx.runUrl} зелёный` : ' · dev не двигался')
+ ' · слито';
default:
return '';
}
}
// ---------------------------------------------------------------------------
// Исполнение: git + gh через `ops`, чтобы тест подменял их целиком.
/**
* #596: сколько вывода готов принять один вызов. Умолчание `spawnSync` — 1 МиБ,
* а `git diff` кандидата несёт три копии бандла: у #594 это 7,1 МБ. Процесс
* убивался по ENOBUFS, `status` становился `null`, и `status ?? 1` выдавало это
* за «git вернул 1» — с УСЕЧЁННЫМ stdout в сообщении об ошибке. Разбор уходил
* в сторону: огрызок диффа выглядит осмысленным.
*/
export const MAX_COMMAND_OUTPUT_BYTES = 256 * 1024 * 1024;
/**
* Запуск с двумя гарантиями: вывод не обрезается молча, а сбой самого запуска
* не выдаёт себя за ненулевой код возврата. `r.error` (ENOBUFS, ENOENT, таймаут)
* уезжает в `stderr` результата, откуда его печатает `must()`.
*/
export const sh = (cmd, args, opts = {}) => {
const r = spawnSync(cmd, args, { encoding: 'utf8', maxBuffer: MAX_COMMAND_OUTPUT_BYTES, ...opts });
const failure = r.error ? `${cmd} не выполнился: ${r.error.code || r.error.message}` : '';
const stderr = [failure, (r.stderr || '').trim()].filter(Boolean).join('\n');
return { status: failure ? 1 : (r.status ?? 1), stdout: (r.stdout || '').trim(), stderr };
};
export function realOps({
repo, token, issue = '', workflow = 'validate.yml', sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
now = Date.now, exec = sh,
candidateTree = (sha) => githubCandidateTree({ repo, sha, token }),
proofContext = (run) => loadGithubProofContext({ repo, run, token }),
}) {
const pushUrl = `https://x-access-token:${token}@github.com/${repo}`;
const git = (...args) => exec('git', args);
const must = (r, what) => { if (r.status !== 0) throw new Error(`${what}: ${r.stderr || r.stdout}`); return r.stdout; };
return {
fetch: (...refs) => must(git('fetch', '-q', 'origin', ...refs), 'git fetch'),
revParse: (ref) => must(git('rev-parse', ref), `rev-parse ${ref}`),
mergeBase: (a, b) => must(git('merge-base', a, b), 'merge-base'),
diffNames: (from, to, pathspec = []) => must(git('diff', '--name-only', from, to, '--', ...pathspec), 'diff').split('\n').filter(Boolean),
// Документы ревью — не часть патча (#516): кандидат несёт свой
// CODE-REVIEW-N-rK.md, материал — нет, и без pathspec их patch-id
// расходились на каждом сдвиге dev; `reviewedFresh` судит так же.
patchId: (from, to) => {
const diff = must(git('diff', '--full-index', from, to, '--', '.', ':!docs/reviews'), 'diff');
const r = spawnSync('git', ['patch-id', '--stable'], { input: diff, encoding: 'utf8' });
return (r.stdout || '').trim().split(' ')[0] || 'empty';
},
rebaseOnto: (branchTip, onto) => {
must(git('checkout', '-q', '-B', 'merge-into-dev', branchTip), 'checkout');
// #643: doc-коммит ветки конфликтует с документами других задач в dev
// только в генерируемом INDEX.md — это решается пересборкой индекса, а
// не возвратом зелёной задачи в S6. Любой другой конфликт — отказ, как
// был; ребейз при отказе уже отменён помощником.
const r = rebaseRegenerating({ onto, gitPrefix: CONVEYOR_IDENTITY });
if (!r.ok) {
console.log(`ребейз на ${onto} отменён: ${r.conflicts.join(', ') || r.reason}`);
return null;
}
// #635 r2: dev мог принести новые документы ревью — снимок INDEX.md
// в кандидате их не знает. Коммит индекса — doc-коммит конвейера:
// patch-id его не видит (`:!docs/reviews`), а тест «индекс свеж» в
// Validate кандидата без него был бы красным.
must(exec(process.execPath, [REVIEWS_INDEX_SCRIPT, '--dir=docs/reviews', '--commit-if-stale', `--issue=${issue}`]), 'reviews-index --commit-if-stale');
return must(git('rev-parse', 'HEAD'), 'rev-parse HEAD');
},
// #657 (1б) r1 H1: документ ревью ветки задачи больше не несёт индекс —
// его пересобирают только коммиты, идущие в dev. Ребейз делает это сам
// (выше); fast-forward, когда dev не двигался, ребейза не знает, и без
// этого шага в dev уехал бы устаревший INDEX.md — красный `reviews_index`
// на голове dev. Коммит индекса — doc-коммит конвейера поверх материала,
// слияние остаётся fast-forward.
freshIndex: (tip) => {
must(git('checkout', '-q', '-B', 'merge-into-dev', tip), 'checkout');
must(exec(process.execPath, [REVIEWS_INDEX_SCRIPT, '--dir=docs/reviews', '--commit-if-stale', `--issue=${issue}`]), 'reviews-index --commit-if-stale');
return must(git('rev-parse', 'HEAD'), 'rev-parse HEAD');
},
pushWithLease: (sha, ref, expected) => {
const r = git('push', '-q', `--force-with-lease=refs/heads/${ref}:${expected}`, pushUrl, `${sha}:refs/heads/${ref}`);
if (r.status === 0) return true;
if (/stale info|rejected|fetch first|lease/i.test(r.stderr)) return false;
throw new Error(`git push ${ref}: ${r.stderr}`);
},
// Мутанты по диффу бегут только по запросу (#510): кандидат после ребейза —
// новое дерево, поэтому слияние запускает Validate с мутантами само и ждёт
// именно этот dispatch-прогон; push-прогон на том же SHA их не содержит.
dispatchValidate: (ref) => {
const r = exec('gh', ['workflow', 'run', workflow, '--repo', repo, '--ref', ref, '-f', 'full=false', '-f', 'mutants=true']);
if (r.status !== 0) throw new Error(`gh workflow run ${workflow}: ${r.stderr || r.stdout}`);
},
waitValidate: async (sha, { event = 'workflow_dispatch' } = {}) => {
const started = now();
let runId = null;
const ignored = new Set();
const tree = await candidateTree(sha);
while (now() - started < VALIDATE_TOTAL_MS) {
const r = exec('gh', ['run', 'list', '--repo', repo, '--workflow', workflow, '--commit', sha, '--json', 'databaseId,status,conclusion,url,event,headSha,attempt,startedAt,createdAt', '--limit', '10']);
const all = r.status === 0 && r.stdout ? JSON.parse(r.stdout) : [];
const runs = all.filter((x) => (!event || x.event === event) && !ignored.has(x.databaseId));
const run = runs.find((x) => x.databaseId === runId) || runs[0];
if (run) {
runId = run.databaseId;
if (run.status === 'completed') {
let context;
try { context = await proofContext(run); }
catch { context = { proof: null, jobs: [], reuseRuns: new Map() }; }
const verdict = evaluateCiProof({
run, ...context, candidate: { sha, tree }, policy: CI_PROOF_POLICIES.merge,
});
if (verdict.status === 'green' || verdict.status === 'failed')
return { result: verdict.status, url: verdict.url, note: verdict.note };
ignored.add(run.databaseId);
runId = null;
continue;
}
} else if (now() - started > VALIDATE_APPEAR_MS) {
return { result: 'missing', url: null };
}
await sleep(20_000);
}
return { result: 'failed', url: runId ? `run ${runId} (timeout)` : null };
},
comment: (issue, body) => {
const r = spawnSync('gh', ['issue', 'comment', String(issue), '--repo', repo, '--body-file', '-'], { input: body, encoding: 'utf8' });
if (r.status !== 0) throw new Error(`gh issue comment: ${r.stderr}`);
},
log: (line) => console.log(line),
};
}
/**
* Слияние по алгоритму §4.2. Возвращает { merged, to, action, candidate }.
*/
export async function mergeCandidate({ branch, material, issue, ops, maxAttempts = MAX_ATTEMPTS }) {
ops.fetch('dev', branch);
const actual = ops.revParse(`origin/${branch}`);
const reviewedFresh = actual === material
|| (safe(() => ops.revParse(`${actual}^`)) === material
&& ops.diffNames(material, actual, ['.', ':!docs/reviews']).length === 0);
const ctx = { branch, material, actual, issue };
const finish = (decision, extra = {}) => {
const body = commentFor(decision.action, { ...ctx, ...extra, attempt: extra.attempt });
if (body) ops.comment(issue, body);
const merged = decision.action === 'push' || decision.action === 'fast-forward';
ops.log(`решение: ${decision.action} → ${decision.to || '(метка по вердикту)'}`);
return { merged, to: decision.to, action: decision.action, candidate: extra.candidate || actual };
};
if (!reviewedFresh) return finish(decideMerge({ fresh: false }));
let tip = actual;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
ops.fetch('dev');
const devNow = ops.revParse('origin/dev');
const materialBase = ops.mergeBase(material, 'origin/dev');
const devMoved = devNow !== materialBase;
ops.log(`попытка ${attempt}: dev@${devNow.slice(0, 8)}, база материала ${materialBase.slice(0, 8)}, dev ${devMoved ? 'двигался' : 'на месте'}`);
if (!devMoved) {
const target = ops.freshIndex(tip);
const pushed = ops.pushWithLease(target, 'dev', devNow);
const decision = decideMerge({ fresh: true, devMoved: false, leaseRejected: !pushed });
if (decision.action === 'retry') continue;
return finish(decision, { candidate: target, devNow });
}
const candidate = ops.rebaseOnto(tip, 'origin/dev');
if (!candidate) return finish(decideMerge({ fresh: true, devMoved: true, conflict: true }), { devNow });
const patchIdEqual = ops.patchId(materialBase, material) === ops.patchId(devNow, candidate);
// кандидат публикуется в ветку в любом случае: он и есть то, что должно
// ехать в dev, и Validate стартует именно от этого push
if (!ops.pushWithLease(candidate, branch, tip)) {
// ветку задачи подвинули, пока шло ревью или ребейз — это #312, не наш случай
return finish(decideMerge({ fresh: false }), { candidate, devNow });
}
tip = candidate;
if (!patchIdEqual) return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: false }), { candidate, devNow });
// мутанты по диффу — по запросу (#510): dispatch на ветке, где теперь стоит кандидат
ops.dispatchValidate(branch);
ops.log(`Validate с мутантами на кандидате ${candidate.slice(0, 8)} — ждём`);
const { result, url } = await ops.waitValidate(candidate, { event: 'workflow_dispatch' });
let decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, attempt, maxAttempts });
if (decision.action !== 'push') return finish(decision, { candidate, devNow, runUrl: url });
const pushed = ops.pushWithLease(candidate, 'dev', devNow);
decision = decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: result, leaseRejected: !pushed, attempt, maxAttempts });
if (decision.action === 'retry') { ops.log('dev двинулся снова — ещё попытка'); continue; }
return finish(decision, { candidate, devNow, runUrl: url, attempt });
}
return finish(decideMerge({ fresh: true, devMoved: true, patchIdEqual: true, validate: 'green', leaseRejected: true, attempt: maxAttempts, maxAttempts }), { candidate: tip, attempt: maxAttempts });
}
const safe = (fn) => { try { return fn(); } catch { return null; } };
const arg = (name) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
if (isMainModule(import.meta.url)) { // #496: переносимо для Windows
const branch = arg('branch');
const material = arg('material');
const issue = arg('issue');
const repo = arg('repo') || process.env.GITHUB_REPOSITORY;
const token = process.env.HP_PROCESS_TOKEN || process.env.TOKEN;
if (!branch || !material || !issue || !repo || !token) {
console.error('usage: merge-candidate.mjs --branch=<issue branch> --material=<sha> --issue=<n> [--repo=owner/name]; HP_PROCESS_TOKEN in env');
process.exit(2);
}
const ops = realOps({ repo, token, issue });
mergeCandidate({ branch, material, issue, ops }).then((r) => {
const out = `merged=${r.merged}\nto=${r.to || ''}\naction=${r.action}\ncandidate=${r.candidate}\n`;
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, out);
process.stdout.write(out);
}, (err) => {
// Инвариант конвейера: после прогона метка меняется всегда. Сбой самого
// слияния — не повод оставить задачу висеть в S7: S6 и внятный комментарий.
console.error(err);
try {
ops.comment(issue, `**Слияние не выполнено: сбой шага слияния (#492).**\n\n\`\`\`\n${String(err && err.message || err).slice(0, 1500)}\n\`\`\`\n\nВердикт ревью в силе. Задача в \`S6-in-progress\`; после разбора сбоя вернуть \`S7-code-review\`.`);
} catch (e) { console.error(e); }
if (process.env.GITHUB_OUTPUT) appendFileSync(process.env.GITHUB_OUTPUT, 'merged=false\nto=S6-in-progress\naction=error\n');
process.exit(0);
});
}