Files
houseplan-card/scripts/reviews-index.mjs
T
Claudeandclaude[bot] d9bd5a446c reviews-index: счётчики из своего вердикта, находки в живых форматах заголовков, колонка файлов (#635 r2)
r1 H1: parseCounts брал первое «High: N» по тексту (часто цитата чужого
раунда), parseFindings знал только «### H1 — …». Теперь: счётчик — своя
строка «Вердикт» → секция «## Вердикт» → единственное значение → подсчёт по
заголовкам; находки — «### Medium (…) — …», секции с «**M1. …**»,
«## Находка N (High…)», «### [High] …», «### Low L1 — …»; «— нет» не
находка. Новая колонка «Файлы» — пути из находок, чтобы grep по имени файла
отвечал на «что находили по X». Пересказ чужого вердикта строчными в шапке
не перебивает свой. INDEX.md пересобран; мутант reviews-index-counts-first-match.

Перезаход без изменения дерева: dispatch-прогон на 3719a06d покраснел
рассинхроном process.yml main/dev (исправлено зеркалом 0c933449), а
validate-gate считает завершённый красный dispatch на SHA окончательным.

Issue: #635
User-Visible: no
2026-09-23 13:52:23 +00:00

298 lines
16 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]
//
// `--check` — не писать, а сравнить с существующим файлом (гейт «индекс свеж»).
import { readdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
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 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 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;
};
/**
* Заголовки находок с их 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;
}
/** `**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] });
}
return items;
}
/**
* Числа High/Medium. Порядок доверия: счётчик в строке или секции «Вердикт»
* собственного документа → счётчик, единственный во всём тексте → подсчёт по
* заголовкам и нумерованным находкам. Первое попавшееся `High: N` по всему
* файлу больше не берётся: r1 #635 показал, что оно бывает цитатой чужого
* документа («ТЗ прошло зелёным на r3 (High: 0, Medium: 0)»).
*/
export function parseCounts(text) {
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 = block.body.map((l) => l.trim()).find((l) => l && !/^[|#<-]/.test(l));
if (first) found.push({ line: block.line, title: first.replace(/^\**[HML]\d+\**[.:—–-]?\s*/, '') });
}
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 };
}
const badge = (verdict) => ({ 'зелёный': '🟢', 'жёлтый': '🟡', 'красный': '🔴' }[verdict] || '⚪');
export function renderIndex({ entries, skipped = [] }) {
const byIssue = new Map();
for (const entry of entries) {
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 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));
}
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));
const markdown = buildIndex(dir);
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} строк`);
}
}