Files
houseplan-card/scripts/reviews-index.mjs
T
Claudeandclaude[bot] e58d7d06f8 feat(process): nightly ship batch review, reused by the beta gate by patch set (#727)
Ship tasks merge without a model review and their code was first read by
the batch review right before a beta: one session over the whole range,
ten to forty-five minutes on the release path, days after the merge. The
gate also knew a single document (SHIP-REVIEW-<tag>.md) and covered tasks
by number only, so a commit that landed after the review under the same
trailer still counted as read.

- scripts/ship-review.mjs: the patch set of a task is the sorted
  `git patch-id --stable` of its range commits, without `Release:`
  commits (the beta candidate carries every Issue: of the line) and
  commits touching only docs/reviews/**; the diff options are explicit
  so a local git config cannot change it. shipCoverage rates every ship
  task from the documents of the same base (candidate and origin/dev,
  latest publication wins): clean, high, stale, none; documents without
  `patches` cover by number. `tag=nightly` is a reserved mode: the
  candidate is required, the document is
  SHIP-REVIEW-<base>-dev-<sha12>.md, only none/stale tasks are read and
  nothing runs when nothing is uncovered. The beta reads the same delta
  (force=true reads everything, as before); the brief names what the
  night already read. The gate refuses none/stale with the command and
  keeps the High refusal with force=true; all clean passes without a tag
  document. The machine block gains `mode` and `patches` at its end.
  comment-high writes one line per task of a nightly document with High,
  once per document (hp:ship-review-high).
- _ship-review.yml: prepare refuses nightly without a candidate before
  defaulting to the dev tip, computes the document from base and SHA and
  no longer reads a prepare failure behind `| tee` as "no ship tasks";
  publish takes mode and patches from prepare, never from the model
  result; a new step comments High at night with HP_PROCESS_TOKEN.
- _nightly.yml: the Validate run SHA is a separate step output before
  the wait; a new job dispatches ship-review.yml -f tag=nightly on it
  whatever Validate's outcome, waits only for the run to appear and
  never colours the night. Thin files in main are unchanged.
- reviews-index/reviews-archive: the nightly name is a ship document
  with nightly: true; a beta base archives with its line, a stable base
  with the nearest archived line newer than the base, or stays.
- PROCESS.md §11.7, §10.4 and REVIEWER.md describe the nightly mode,
  patch set, coverage and beta delta; the digest test pins the key rule.

Tests run the prepare, publish and comment steps and the nightly steps
on real bash with real git in temporary repositories; only push
transport and gh are faked.

Issue: #727
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 03:25:24 +00:00

412 lines
24 KiB
JavaScript
Raw 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
// #635: индекс документов ревью — база знаний решений, которую можно прочитать
// за одно чтение.
//
// В `docs/reviews/` ≈ 1 000 документов; что находили по файлу, диалогу или
// подсистеме, узнать можно было только перечитав их. Индекс сводит каждый
// документ в одну строку: issue, этап, раунд, вердикт, число High/Medium и
// заголовки находок. Он генерируется (класс C), а не пишется руками, и
// пересобирается конвейером после публикации каждого документа ревью.
//
// node scripts/reviews-index.mjs [--dir=docs/reviews] [--output=docs/reviews/INDEX.md] [--check] [--strict]
// node scripts/reviews-index.mjs --commit-if-stale --issue=NN [--dir=…]
//
// `--check` — не писать, а сравнить с существующим файлом (гейт «индекс свеж»;
// тот же инвариант держит тест `#635 индекс свеж`).
// `--strict` — отказать до записи, если в каталоге есть неизвестное имя
// документа; точка публикации release-review использует этот режим.
// `--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';
export const INDEX_FILE = 'INDEX.md';
const DOC_NAME = /^(CODE|SPEC)-REVIEW-(?:issue-)?(\d+)(?:-r(\d+))?(?:-([a-z0-9-]+))?\.md$/i;
const RELEASE_DOC_NAME = /^RELEASE-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*))\.md$/i;
// #696: пакетное ревью ship-задач перед бетой (PROCESS.md §11.7) — по тегу беты.
// #727: ночной документ `SHIP-REVIEW-<база>-dev-<sha12>.md` — тег здесь база диапазона.
const SHIP_DOC_NAME = /^SHIP-REVIEW-(v(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-beta\.(?:0|[1-9]\d*))?)(-dev-[0-9a-f]{12})?\.md$/i;
const COLOUR = {
'зелёный': 'зелёный', 'зеленый': 'зелёный', green: 'зелёный',
'жёлтый': 'жёлтый', 'желтый': 'жёлтый', yellow: 'жёлтый',
'красный': 'красный', red: 'красный',
};
const COLOUR_RE = /(зелёный|зеленый|жёлтый|желтый|красный|green|yellow|red)(?![а-яёa-z])/i;
const VERDICT_LINE_RE = /(?:[Вв]ердикт|[Vv]erdict)[^\n]{0,60}?\**\s*(зелёный|зеленый|жёлтый|желтый|красный|green|yellow|red)(?![а-яёa-z])/i;
/** Строка, НАЧИНАЮЩАЯСЯ с «Вердикт» (с заглавной, после `- `/`**`): своя, а не пересказ чужого раунда. */
// Без флага `i`: строчное «вердикт красный» в шапке — пересказ, а не свой вердикт.
const VERDICT_OWN_LINE_RE = /^[ \t]*(?:[-*]\s*)?\**(?:Вердикт|Verdict)[^\n]{0,60}?\**\s*([Зз]елёный|[Зз]еленый|[Жж]ёлтый|[Жж]елтый|[Кк]расный|[Gg]reen|[Yy]ellow|[Rr]ed)(?![а-яёa-z])/m;
/** Разобрать имя документа: этап, issue, раунд. */
export function parseDocName(name) {
const release = RELEASE_DOC_NAME.exec(String(name));
if (release) return { stage: 'release', issue: null, round: null, suffix: null, tag: release[1] };
const ship = SHIP_DOC_NAME.exec(String(name));
if (ship) return { stage: 'ship', issue: null, round: null, suffix: null, tag: ship[1], ...(ship[2] ? { nightly: true } : {}) };
const match = DOC_NAME.exec(String(name));
if (!match) return null;
return {
stage: match[1].toUpperCase() === 'CODE' ? 'code' : 'spec',
issue: Number(match[2]),
round: match[3] ? Number(match[3]) : null,
suffix: match[4] || null,
};
}
/**
* Вердикт. Порядок доверия: своя строка «Вердикт: цвет» с начала строки →
* секция «## Вердикт» → упоминание «вердикт цвет» где угодно → свободная
* форма хвоста → «—». r1 #635: документ r2 пересказывал вердикт r1
* («вердикт красный, High: 1») в шапке, и первое совпадение по тексту
* выдавало чужой цвет.
*/
export function parseVerdict(text) {
const own = VERDICT_OWN_LINE_RE.exec(text);
if (own) return COLOUR[own[1].toLowerCase()];
const section = verdictSection(text);
if (section != null) {
const colour = COLOUR_RE.exec(section);
if (colour) return COLOUR[colour[1].toLowerCase()];
if (/блокиру|не принят|отклон|red/i.test(section)) return 'красный';
if (/принят|принимается|готов|без замечаний|можно сливать|регрессий нет|proceed|approved/i.test(section)) return 'зелёный';
}
const explicit = VERDICT_LINE_RE.exec(text);
if (explicit) return COLOUR[explicit[1].toLowerCase()];
// Старые документы пишут «зелёный вердикт» в свободной форме — ищем в хвосте.
const tail = /(зелёный|зеленый|жёлтый|желтый|красный|green|yellow|red)\**\s+вердикт/i.exec(text.slice(-2500));
if (tail) return COLOUR[tail[1].toLowerCase()];
return '—';
}
const VERDICT_SECTION_RE = /^#{1,4}\s*(?:\d+\.\s*)?(?:Вердикт|Verdict|Итог)(?![а-яё])[^\n]*\n([\s\S]*?)(?=\n#{1,4}\s|(?![\s\S]))/m;
const SEVERITY = { high: 'high', h: 'high', medium: 'medium', m: 'medium', low: 'low', l: 'low' };
/** Заголовок находки: `### H1 — …`, `### Medium-2 (…) — …`, `### Medium (в скоупе) — …`, `### Low`. */
const SEVERITY_HEADING_RE = /^(#{2,4})\s*\**\[?(High|Medium|Low|[HML])(?:[-\s]?(?:[HML])?(\d+)[a-z-]*)?\]?\**(?:\s*\([^)\n]*\))?\s*(?:[—–:.-]\s*)?(.*)$/gmi;
/** `## Находка 1 (High, в скоупе) — title` — форма ранних документов; группы те же, что у SEVERITY_HEADING_RE. */
const FINDING_HEADING_RE = /^(#{2,4})\s*Находка\s*(\d+)?\s*\((High|Medium|Low)[^)\n]*\)\s*(?:[—–:.-]\s*)?(.*)$/i;
const NOTHING_RE = /^\s*[—–-]?\s*(?:нет|не найдено|не обнаружено|отсутствуют|не блокиру\S*|снима\S*(?:\s+с\s+записью)?|none|no|—)\s*[.,;]?\s*$/i;
/** Строка вердикта — та, где стоит слово «Вердикт» и рядом счётчик High/Medium. */
function verdictLine(text, own = false) {
const marker = own ? /^[ \t]*(?:[-*]\s*)?\**(?:Вердикт|Verdict)/ : /(?:[Вв]ердикт|[Vv]erdict)/;
return text.split('\n').find((line) => marker.test(line) && /(?:High|Medium):\s*\d+/.test(line)) || null;
}
/** Секция «## Вердикт» (тело до следующего заголовка) либо null. */
function verdictSection(text) {
const match = VERDICT_SECTION_RE.exec(text);
return match ? match[1] : null;
}
const countsIn = (scope) => {
const high = /High:\s*(\d+)/.exec(scope);
const medium = /Medium:\s*(\d+)/.exec(scope);
return high || medium ? { high: high ? Number(high[1]) : 0, medium: medium ? Number(medium[1]) : 0 } : null;
};
const releaseCounts = (text) => {
const match = /(?:^|\n)Итог:\s*High\s+(\d+)\s*·\s*Medium\s+(\d+)(?:\s*·\s*Low\s+\d+)?(?:\s|$)/i.exec(text);
return match ? { high: Number(match[1]), medium: Number(match[2]) } : null;
};
/**
* Заголовки находок с их severity и телом секции. Тело — строки до
* следующего заголовка того же или более высокого уровня.
*/
function severityBlocks(text) {
const lines = text.split('\n');
const blocks = [];
for (let i = 0; i < lines.length; i += 1) {
SEVERITY_HEADING_RE.lastIndex = 0;
let m = SEVERITY_HEADING_RE.exec(lines[i]);
let [severity, id] = m ? [m[2], m[3]] : [];
if (!m) {
m = FINDING_HEADING_RE.exec(lines[i]);
if (!m) continue;
[severity, id] = [m[3], m[2]];
}
const level = m[1].length;
const body = [];
for (let j = i + 1; j < lines.length; j += 1) {
const next = /^(#{1,4})\s/.exec(lines[j]);
if (next && next[1].length <= level) break;
body.push(lines[j]);
}
blocks.push({ line: i, severity: SEVERITY[severity.toLowerCase()], id: id ? Number(id) : null, title: (m[4] || '').trim(), body });
}
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 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;
}
/**
* Числа High/Medium. Порядок доверия: счётчик в строке или секции «Вердикт»
* собственного документа → счётчик, единственный во всём тексте → подсчёт по
* заголовкам и нумерованным находкам. Первое попавшееся `High: N` по всему
* файлу больше не берётся: r1 #635 показал, что оно бывает цитатой чужого
* документа («ТЗ прошло зелёным на r3 (High: 0, Medium: 0)»).
*/
export function parseCounts(text) {
const release = releaseCounts(text);
if (release) return release;
for (const scope of [verdictLine(text, true), verdictSection(text), verdictLine(text)]) {
const counts = scope ? countsIn(scope) : null;
if (counts) return counts;
}
const highs = new Set([...text.matchAll(/High:\s*(\d+)/g)].map((m) => m[1]));
const mediums = new Set([...text.matchAll(/Medium:\s*(\d+)/g)].map((m) => m[1]));
if ((highs.size || mediums.size) && highs.size <= 1 && mediums.size <= 1) {
return { high: Number([...highs][0] || 0), medium: Number([...mediums][0] || 0) };
}
const ids = { high: new Set(), medium: new Set() };
let anonymous = { high: 0, medium: 0 };
for (const block of severityBlocks(text)) {
if (block.severity === 'low') continue;
if (block.id != null) { ids[block.severity].add(block.id); continue; }
const items = numberedItems(block.body).filter((item) => item.severity === block.severity);
if (items.length) { for (const item of items) ids[block.severity].add(item.id); continue; }
const first = block.body.map((l) => l.trim()).find(Boolean) || '';
if (NOTHING_RE.test(block.title || first) || (!block.title && !first)) continue;
anonymous = { ...anonymous, [block.severity]: anonymous[block.severity] + 1 };
}
return { high: ids.high.size + anonymous.high, medium: ids.medium.size + anonymous.medium };
}
/**
* Заголовки находок: `### 1. …`, `### H1 — …`, `### M2: …`,
* `### Medium (в скоупе) — …`, секция `### Medium` с `**M1. …**` внутри или
* с первым абзацем как заголовком, строки таблиц с severity в первых
* ячейках. Обрезаются до 90 символов; не больше `limit`.
*/
export function parseFindings(text, limit = 6) {
const out = [];
const seen = new Set();
const push = (raw) => {
// Снимается разметка, а не символы: `_` внутри `smoke_links.mjs` — часть имени.
const title = String(raw).replace(/[`*]/g, '').replace(/(^|\s)_+|_+(\s|$)/g, '$1$2').replace(/\s+/g, ' ').trim()
.replace(/^[HML]\d+\s*[.:—–-]\s*/, '').replace(/[.,:;—–-]+$/, '').trim();
if (!title || NOTHING_RE.test(title) || seen.has(title)) return;
seen.add(title);
out.push(title.length > 90 ? `${title.slice(0, 87)}…` : title);
};
// Источники сливаются в порядке документа: заголовок r1 не должен уступать
// место таблице из конца файла только потому, что он другой формы.
const found = [];
const lines = text.split('\n');
lines.forEach((line, index) => {
const numbered = /^#{3,4}\s*\d+\.\s*([^\n]+)/.exec(line);
if (numbered) found.push({ line: index, title: numbered[1] });
const row = /^\|\s*(?:\*\*)?(?:H\d+|M\d+|High|Medium)(?:\*\*)?\s*\|(?:[^|\n]*\|)?\s*([^|\n]+)\|/.exec(line);
if (row) found.push({ line: index, title: row[1] });
});
for (const block of severityBlocks(text)) {
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 = 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);
return out.slice(0, limit);
}
/**
* Файлы, названные в находках: пути в обратных кавычках внутри секций
* High/Medium/Low (с номером строки или без). Это и есть ответ на «что
* находили по файлу X» — `grep 'form-kit' INDEX.md` (r1 #635 AC2).
*/
export function parseFiles(text, limit = 8) {
const out = [];
const seen = new Set();
const blocks = severityBlocks(text);
const scope = blocks.length ? blocks.map((b) => [b.title, ...b.body].join('\n')).join('\n') : '';
for (const m of scope.matchAll(/`((?:[\w@.-]+\/)*[\w@.-]+\.(?:ts|mjs|js|py|md|yml|yaml|json|css|html|sh|ps1|mermaid))(?::\d+(?:[-–]\d+)?)?`/g)) {
const file = m[1].replace(/^\.\//, '');
if (seen.has(file)) continue;
seen.add(file);
out.push(file);
if (out.length >= limit) break;
}
return out;
}
/** Одна запись индекса по документу. */
export function indexEntry(name, text) {
const meta = parseDocName(name);
if (!meta) return null;
return {
name, ...meta,
verdict: parseVerdict(text),
...parseCounts(text),
findings: parseFindings(text),
files: parseFiles(text),
};
}
export function collectEntries(dir, read = (name) => readFileSync(join(dir, name), 'utf8')) {
const names = readdirSync(dir).filter((name) => name.endsWith('.md') && name !== INDEX_FILE).sort();
const entries = [];
const skipped = [];
for (const name of names) {
const entry = indexEntry(name, read(name));
if (entry) entries.push(entry); else skipped.push(name);
}
return { entries, skipped };
}
/** Строгая граница публикации: новый документ обязан попасть в индекс сразу. */
export function assertAllDocumentsIndexed({ skipped }) {
if (skipped.length) throw new Error(`вне схемы имён: ${skipped.join(', ')}`);
}
const badge = (verdict) => ({ 'зелёный': '🟢', 'жёлтый': '🟡', 'красный': '🔴' }[verdict] || '⚪');
export function renderIndex({ entries, skipped = [] }) {
// Документы линии и беты (release, ship) — без issue; свежий тег выше.
// Стабильный тег старше своих бет: `v1.79.0` выше `v1.79.0-beta.3`.
const tagKey = (tag) => {
const [core, beta] = tag.slice(1).split('-beta.');
return [...core.split('.').map(Number), beta == null ? Infinity : Number(beta)];
};
const releaseDocs = entries.filter((entry) => entry.issue == null).sort((a, b) => {
const av = tagKey(a.tag);
const bv = tagKey(b.tag);
for (let i = 0; i < 4; i += 1) if (av[i] !== bv[i]) return bv[i] - av[i];
// #727: ночной документ читал код после своей базы — он новее документов этого тега.
if (Boolean(a.nightly) !== Boolean(b.nightly)) return a.nightly ? -1 : 1;
return a.stage.localeCompare(b.stage) || a.name.localeCompare(b.name);
});
const byIssue = new Map();
for (const entry of entries.filter((item) => item.issue != null)) {
const list = byIssue.get(entry.issue) || [];
list.push(entry);
byIssue.set(entry.issue, list);
}
const issues = [...byIssue.keys()].sort((a, b) => b - a);
const lines = [];
lines.push('# Индекс ревью');
lines.push('');
lines.push(`Генерируется \`node scripts/reviews-index.mjs\` (#635) — не редактировать руками. Документов: ${entries.length}, issue: ${issues.length}. Вердикт: 🟢 зелёный · 🟡 жёлтый · 🔴 красный · ⚪ не распознан (свободная форма старых документов). H/M — число High/Medium по строке вердикта или заголовкам находок. Файлы — пути, названные в находках; ищите по имени файла: \`grep form-kit INDEX.md\`.`);
lines.push('');
lines.push('| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |');
lines.push('|---|---|---|---|---:|---:|---|---|');
for (const doc of releaseDocs) {
const [who, what] = doc.nightly ? ['ночь после', 'ночное пакетное ревью ship']
: doc.stage === 'ship' ? ['бета', 'пакетное ревью ship'] : ['линия', 'ревью линии'];
lines.push(`| ${who} ${doc.tag} | [${doc.name}](${doc.name}) | ${what} · — | ${badge(doc.verdict)} ${doc.verdict} | ${doc.high} | ${doc.medium} | ${doc.findings.join('; ').replace(/\|/g, '\\|') || '—'} | ${(doc.files || []).map((f) => `\`${f}\``).join(' ') || '—'} |`);
}
for (const issue of issues) {
const docs = byIssue.get(issue).sort((a, b) => (a.stage === b.stage ? (a.round || 0) - (b.round || 0) : a.stage === 'spec' ? -1 : 1));
for (const doc of docs) {
const round = doc.round ? `r${doc.round}` : (doc.suffix || '—');
lines.push(`| #${issue} | [${doc.name}](${doc.name}) | ${doc.stage} · ${round} | ${badge(doc.verdict)} ${doc.verdict} | ${doc.high} | ${doc.medium} | ${doc.findings.join('; ').replace(/\|/g, '\\|') || '—'} | ${(doc.files || []).map((f) => `\`${f}\``).join(' ') || '—'} |`);
}
}
if (skipped.length) {
lines.push('');
lines.push(`Вне схемы имён (не индексируются): ${skipped.map((name) => `\`${name}\``).join(', ')}.`);
}
return `${lines.join('\n')}\n`;
}
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 collected = collectEntries(dir);
if (process.argv.includes('--strict')) assertAllDocumentsIndexed(collected);
const markdown = renderIndex(collected);
if (process.argv.includes('--check')) {
const current = existsSync(output) ? readFileSync(output, 'utf8') : '';
if (current !== markdown) {
console.error(`${output} устарел — пересобрать: node scripts/reviews-index.mjs`);
process.exit(1);
}
console.log(`${output} свеж`);
} else {
writeFileSync(output, markdown, 'utf8');
console.log(`${output}: ${markdown.split('\n').length} строк`);
}
}