mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-07 23:19:14 +00:00
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
298 lines
16 KiB
JavaScript
298 lines
16 KiB
JavaScript
#!/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} строк`);
|
||
}
|
||
}
|