#!/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; 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 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) || /^