reviews-index: свежесть индекса после ребейзов конвейера, первый абзац находки целиком (#635 r3)

r2 H1: INDEX.md — снимок каталога, и ребейз ветки на dev, получивший чужие
документы ревью, устаревал его молча. Теперь `--commit-if-stale` пересобирает
и коммитит индекс коммитом конвейера после приведения к dev (process.yml) и
после ребейза кандидата (merge-candidate.mjs); тест «индекс свеж» сравнивает
закоммиченный файл с пересборкой и красит Validate при расхождении.

r2 M1: находка без заголовка — первый абзац секции, склеенный из перенесённых
строк, без маркера буллета и кода `**M1.**`; «не найдено», служебные скобки
«(унаследовано…)» — не находка. Нумерованные пункты тоже забирают перенесённые
строки. Мутант reviews-index-paragraph-tail. PROCESS.md §2.10 дополнен.

Issue: #635
User-Visible: no
This commit is contained in:
Claude
2026-09-23 13:52:23 +00:00
committed by claude[bot]
parent f855cee891
commit 49bae62e9a
8 changed files with 288 additions and 100 deletions
+11 -2
View File
@@ -22,10 +22,14 @@
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';
/** Скрипт индекса — по абсолютному пути: слияние работает из чужого 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;
@@ -121,7 +125,7 @@ export const sh = (cmd, args, opts = {}) => {
};
export function realOps({
repo, token, workflow = 'validate.yml', sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
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 }),
@@ -146,6 +150,11 @@ export function realOps({
must(git('checkout', '-q', '-B', 'merge-into-dev', branchTip), 'checkout');
const r = spawnSync('git', ['-c', 'user.name=claude[bot]', '-c', 'user.email=209825114+claude[bot]@users.noreply.github.com', 'rebase', onto], { encoding: 'utf8' });
if (r.status !== 0) { spawnSync('git', ['rebase', '--abort']); 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');
},
pushWithLease: (sha, ref, expected) => {
@@ -278,7 +287,7 @@ if (isMainModule(import.meta.url)) { // #496: переносимо для Window
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 });
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);
+11
View File
@@ -8594,6 +8594,17 @@ const MUTANT_DEFINITIONS = [
replace: " for (const scope of [text]) { // mutant: first match anywhere in the document",
}],
},
{
id: 'reviews-index-paragraph-tail',
guard: 'node --test --test-name-pattern="#635 r2: первый абзац" test/reviews-index.test.mjs',
because: 'r2 #635 M1: a finding without a heading is its first paragraph joined from wrapped '
+ 'lines; taking a single physical line yields the tail of a bullet («пусто). Не эскалирую…»)',
patches: [{
file: 'scripts/reviews-index.mjs',
find: " current.push(line);\n }\n for (const paragraph of paragraphs) {",
replace: " current = [line]; // mutant: last physical line instead of the paragraph\n }\n for (const paragraph of paragraphs) {",
}],
},
{
id: 'process-reconcile-restarts-healthy-run',
guard: 'node --test test/process-reconcile.test.mjs',
+77 -6
View File
@@ -9,9 +9,17 @@
// пересобирается конвейером после публикации каждого документа ревью.
//
// node scripts/reviews-index.mjs [--dir=docs/reviews] [--output=docs/reviews/INDEX.md] [--check]
// node scripts/reviews-index.mjs --commit-if-stale --issue=NN [--dir=…]
//
// `--check` — не писать, а сравнить с существующим файлом (гейт «индекс свеж»).
// `--check` — не писать, а сравнить с существующим файлом (гейт «индекс свеж»;
// тот же инвариант держит тест `#635 индекс свеж`).
// `--commit-if-stale` — пересобрать и, если файл изменился, закоммитить его
// коммитом конвейера (класс C). Индекс — снимок каталога: ребейз ветки на
// dev, получивший новые документы, устаревает его молча (r2 #635 H1), поэтому
// конвейер зовёт этот режим после каждого своего ребейза — при приведении к
// dev перед ревью и при слиянии кандидата.
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { isMainModule } from './spawn-portable.mjs';
@@ -119,12 +127,42 @@ function severityBlocks(text) {
return blocks;
}
/**
* Первый абзац тела секции как заголовок находки без заголовка. Абзац — до
* пустой строки, перенесённые строки склеиваются: иначе у буллета, не
* уместившегося в одну физическую строку, брался его хвост (r2 #635 M1,
* `CODE-REVIEW-485-r4`: «пусто). Не эскалирую…»). Маркер буллета и код
* `**M1.**` снимаются; абзац, начинающийся с «не найдено»/«нет», — не находка.
*/
function firstParagraph(body) {
const paragraphs = [];
let current = [];
for (const raw of [...body, '']) {
const line = raw.trim();
if (!line) { if (current.length) paragraphs.push(current.join(' ')); current = []; continue; }
if (/^[|#<]/.test(line) || /^<!--/.test(line)) { if (current.length) paragraphs.push(current.join(' ')); current = []; continue; }
current.push(line);
}
for (const paragraph of paragraphs) {
const text = paragraph.replace(/^[-*]\s+/, '').replace(/^\**[HML]\d+\**\s*[.:—–-]?\s*/, '').trim();
if (!text || text.startsWith('(')) continue; // служебная скобка «(унаследовано из r1…)» — не находка
if (NOTHING_RE.test(text) || /^(?:\**(?:High|Medium|Low)\**\s*)?(?:не найдено|не обнаружено|нет находок|нет\b|отсутству)/i.test(text)) return null;
return text;
}
return null;
}
/** `**M1. …**`, `**H2 — …**`, `- M3: …` в теле секции — пронумерованные находки без заголовка. */
function numberedItems(body) {
const items = [];
for (const line of body) {
const m = /^\s*(?:[-*]\s*)?\**([HML])(\d+)\**\s*[.:—–-]\s*(.+)$/.exec(line);
if (m) items.push({ severity: SEVERITY[m[1].toLowerCase()], id: Number(m[2]), title: m[3] });
for (const raw of body) {
const line = raw.trim();
const m = /^(?:[-*]\s*)?\**([HML])(\d+)\**\s*[.:—–-]\s*(.+)$/.exec(line);
if (m) { items.push({ severity: SEVERITY[m[1].toLowerCase()], id: Number(m[2]), title: m[3] }); continue; }
// Перенесённая строка того же пункта — продолжение заголовка (r2 #635 M1).
const last = items[items.length - 1];
if (last && line && !/^[|#<>-]/.test(line) && !last.closed) last.title += ` ${line}`;
else if (last) last.closed = true;
}
return items;
}
@@ -191,8 +229,8 @@ export function parseFindings(text, limit = 6) {
if (block.title) { found.push({ line: block.line, title: block.title }); continue; }
const items = numberedItems(block.body);
if (items.length) { items.forEach((item, k) => found.push({ line: block.line + k / 100, title: item.title })); continue; }
const first = block.body.map((l) => l.trim()).find((l) => l && !/^[|#<-]/.test(l));
if (first) found.push({ line: block.line, title: first.replace(/^\**[HML]\d+\**[.:—–-]?\s*/, '') });
const first = firstParagraph(block.body);
if (first) found.push({ line: block.line, title: first });
}
found.sort((a, b) => a.line - b.line);
for (const item of found) push(item.title);
@@ -278,10 +316,43 @@ export function buildIndex(dir) {
return renderIndex(collectEntries(dir));
}
export const CONVEYOR_IDENTITY = ['-c', 'user.name=claude[bot]', '-c', 'user.email=209825114+claude[bot]@users.noreply.github.com'];
/**
* Пересобрать индекс и закоммитить, если он изменился. Возвращает
* `{ changed, sha }`: `sha` — новый HEAD при коммите, иначе null.
* `git` — исполнитель `(args) => { status, stdout, stderr }` (для теста).
*/
export function commitIfStale({ dir, output = join(dir, INDEX_FILE), issue, git = defaultGit }) {
if (!existsSync(dir)) return { changed: false, sha: null }; // каталога нет — индексировать нечего
const markdown = buildIndex(dir);
const current = existsSync(output) ? readFileSync(output, 'utf8') : '';
if (current === markdown) return { changed: false, sha: null };
writeFileSync(output, markdown, 'utf8');
const must = (args, what) => {
const r = git(args);
if (r.status !== 0) throw new Error(`${what}: ${r.stderr || r.stdout}`);
return String(r.stdout || '').trim();
};
must(['add', '--', output], 'git add');
const trailer = issue ? `\n\nIssue: #${issue}\nUser-Visible: no\n` : '\n\nUser-Visible: no\n';
must([...CONVEYOR_IDENTITY, 'commit', '-q', '-m', `docs(reviews): индекс после сдвига каталога${issue ? ` (#${issue})` : ''}${trailer}`], 'git commit');
return { changed: true, sha: must(['rev-parse', 'HEAD'], 'rev-parse') };
}
function defaultGit(args) {
return spawnSync('git', args, { encoding: 'utf8' });
}
if (isMainModule(import.meta.url)) {
const arg = (name, fallback) => process.argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3) ?? fallback;
const dir = arg('dir', 'docs/reviews');
const output = arg('output', join(dir, INDEX_FILE));
if (process.argv.includes('--commit-if-stale')) {
const { changed, sha } = commitIfStale({ dir, output, issue: arg('issue', '') });
console.log(changed ? `${output} пересобран и закоммичен: ${sha}` : `${output} свеж — коммит не нужен`);
process.exit(0);
}
const markdown = buildIndex(dir);
if (process.argv.includes('--check')) {
const current = existsSync(output) ? readFileSync(output, 'utf8') : '';