// #635: индекс ревью — одна строка на документ, детерминированно, 100 % каталога. import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { INDEX_FILE, assertAllDocumentsIndexed, buildIndex, collectEntries, commitIfStale, indexEntry, parseCounts, parseDocName, parseFiles, parseFindings, parseVerdict, renderIndex, } from '../scripts/reviews-index.mjs'; test('#635 имена документов: этап, issue, раунд; INDEX и чужое — вне схемы', () => { assert.deepEqual(parseDocName('CODE-REVIEW-600-r2.md'), { stage: 'code', issue: 600, round: 2, suffix: null }); assert.deepEqual(parseDocName('SPEC-REVIEW-7-r1.md'), { stage: 'spec', issue: 7, round: 1, suffix: null }); assert.deepEqual(parseDocName('CODE-REVIEW-issue-5.md'), { stage: 'code', issue: 5, round: null, suffix: null }); assert.deepEqual(parseDocName('RELEASE-REVIEW-v1.78.0.md'), { stage: 'release', issue: null, round: null, suffix: null, tag: 'v1.78.0', }); assert.equal(parseDocName('RELEASE-REVIEW-v1.78.0-beta.1.md'), null); assert.equal(parseDocName('INDEX.md'), null); assert.equal(parseDocName('README.md'), null); }); test('#635 вердикт: явная строка, раздел «Вердикт», свободная форма хвоста, иначе «—»', () => { assert.equal(parseVerdict('- Вердикт: **зелёный**'), 'зелёный'); assert.equal(parseVerdict('Вердикт: жёлтый · заход r1 · High: 0 · Medium: 4'), 'жёлтый'); assert.equal(parseVerdict('Verdict: **red** · cycle r2/4'), 'красный'); assert.equal(parseVerdict('## 6. Вердикт\n\nH1 воспроизведён. Это High: блокирует.\n\n## 7. Прочее'), 'красный'); assert.equal(parseVerdict('## Вердикт\n\nПродуктовый код не менялся, регрессий нет.\n'), 'зелёный'); assert.equal(parseVerdict('текст без вердикта'), '—'); assert.equal(parseVerdict('…поэтому **зелёный вердикт**.'), 'зелёный'); assert.equal(parseVerdict('зелёныйзаголовок вердикта'), '—', 'цвет как часть слова не считается'); assert.equal(parseVerdict('Вердикт: зелёныйзаголовок без цвета'), '—', 'JS \\b не знает кириллицы — граница слова явная'); }); test('#635 счётчики и находки', () => { assert.deepEqual(parseCounts('Вердикт: жёлтый · High: 1 · Medium: 3'), { high: 1, medium: 3 }); assert.deepEqual(parseCounts('Итог: High 2 · Medium 4 · Low 1'), { high: 2, medium: 4 }); assert.deepEqual(parseCounts('### H1 — a\n### M1 — b\n### M2 — c\n### L1 — d'), { high: 1, medium: 2 }); assert.deepEqual(parseCounts('ничего'), { high: 0, medium: 0 }); const findings = parseFindings([ '### 1. Room settings: подпись переносится посреди слова', '### H1 — диалог невидим после крестика', '### M2: `rangeLine` клампит каждый символ.', '| M3 | `src/x.ts:1` | поле пустое, состояние старое | почему |', '| **High** | `y.ts` | импорт теряет маршруты | … |', '### 1. Room settings: подпись переносится посреди слова', `### 2. ${'очень длинный заголовок '.repeat(8)}`, ].join('\n')); assert.equal(findings[0], 'Room settings: подпись переносится посреди слова'); assert.equal(findings[1], 'диалог невидим после крестика'); assert.equal(findings[2], 'rangeLine клампит каждый символ'); assert.ok(findings.includes('поле пустое, состояние старое')); assert.ok(findings.includes('импорт теряет маршруты')); assert.equal(findings.length, 6, 'дубль снят, потолок 6'); assert.ok(findings.every((f) => f.length <= 90)); }); test('#635 индекс покрывает каталог целиком, детерминирован и не индексирует сам себя', () => { const dir = mkdtempSync(join(tmpdir(), 'hp-reviews-index-')); try { writeFileSync(join(dir, 'CODE-REVIEW-600-r1.md'), '# x\nВердикт: **жёлтый** · High: 0 · Medium: 4\n### 1. Первая\n### 2. Вторая\n'); writeFileSync(join(dir, 'CODE-REVIEW-600-r2.md'), '# x\nВердикт: **зелёный** · High: 0 · Medium: 0\n'); writeFileSync(join(dir, 'SPEC-REVIEW-600-r1.md'), '- Вердикт: **зелёный**\n'); writeFileSync(join(dir, 'CODE-REVIEW-601-r1.md'), '## Вердикт\n\nсвободная форма\n'); writeFileSync(join(dir, 'RELEASE-REVIEW-v1.78.0.md'), '# Ревью линии\nИтог: High 1 · Medium 2 · Low 0\n### High — риск линии\n'); writeFileSync(join(dir, 'INDEX.md'), 'старый индекс'); writeFileSync(join(dir, 'notes.md'), 'постороннее'); const { entries, skipped } = collectEntries(dir); assert.equal(entries.length, 5); assert.deepEqual(skipped, ['notes.md']); assert.doesNotThrow(() => assertAllDocumentsIndexed({ skipped: [] })); assert.throws(() => assertAllDocumentsIndexed({ skipped }), /вне схемы имён: notes\.md/); const md = buildIndex(dir); assert.equal(md, buildIndex(dir), 'детерминирован'); assert.match(md, /Документов: 5, issue: 2/); const rows = md.split('\n').filter((l) => l.startsWith('| #')); assert.deepEqual(rows.map((r) => r.split('|')[2].trim()), [ '[CODE-REVIEW-601-r1.md](CODE-REVIEW-601-r1.md)', '[SPEC-REVIEW-600-r1.md](SPEC-REVIEW-600-r1.md)', '[CODE-REVIEW-600-r1.md](CODE-REVIEW-600-r1.md)', '[CODE-REVIEW-600-r2.md](CODE-REVIEW-600-r2.md)', ], 'новые issue сверху; внутри issue — ТЗ, затем код по раундам'); assert.match(md, /\| #600 \| \[CODE-REVIEW-600-r1\.md\][^\n]*\| code · r1 \| 🟡 жёлтый \| 0 \| 4 \| Первая; Вторая \|/); assert.match(md, /\| #601 \|[^\n]*⚪ — \|/); assert.match(md, /\| линия v1\.78\.0 \| \[RELEASE-REVIEW-v1\.78\.0\.md\][^\n]*\| ревью линии · — \| [^|]* \| 1 \| 2 \|/); assert.match(md, /Вне схемы имён[^\n]*`notes\.md`/); assert.ok(!md.includes('INDEX.md](INDEX.md)'), 'индекс не индексирует себя'); assert.equal(indexEntry('INDEX.md', 'x'), null); assert.equal(renderIndex({ entries: [] }).includes('Документов: 0'), true); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('#670 CLI --strict принимает ревью линии и отклоняет неизвестное имя до записи индекса', () => { const dir = mkdtempSync(join(tmpdir(), 'hp-reviews-index-strict-')); const script = fileURLToPath(new URL('../scripts/reviews-index.mjs', import.meta.url)); const output = join(dir, INDEX_FILE); const run = () => spawnSync(process.execPath, [script, `--dir=${dir}`, `--output=${output}`, '--strict'], { encoding: 'utf8' }); try { writeFileSync(join(dir, 'RELEASE-REVIEW-v1.78.0.md'), 'Итог: High 1 · Medium 2 · Low 0\n'); const accepted = run(); assert.equal(accepted.status, 0, accepted.stderr || accepted.stdout); assert.match(readFileSync(output, 'utf8'), /\| линия v1\.78\.0 \|/); writeFileSync(join(dir, 'notes.md'), 'неизвестный документ\n'); const before = readFileSync(output, 'utf8'); const rejected = run(); assert.notEqual(rejected.status, 0, 'неизвестное имя обязано остановить публикацию'); assert.match(rejected.stderr, /вне схемы имён: notes\.md/); assert.equal(readFileSync(output, 'utf8'), before, 'strict падает до перезаписи индекса'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('#635 живой каталог docs/reviews: индекс свеж и покрывает все документы', () => { const { entries, skipped } = collectEntries(fileURLToPath(new URL('../docs/reviews/', import.meta.url))); assert.equal(skipped.length, 0, `вне схемы имён: ${skipped.join(', ')}`); // #682: выпущенные линии уезжают в legacy/reviews/<тег>/ — здесь только // текущая линия, и её размер не константа. Пустой каталог — тоже поломка. assert.ok(entries.length > 0); const recognised = entries.filter((e) => e.verdict !== '—').length; assert.ok(recognised / entries.length > 0.9, `вердикт распознан у ${recognised} из ${entries.length}`); }); // r2 #635 H1: закоммиченный INDEX.md обязан совпадать с пересборкой — иначе // документы, приехавшие ребейзом, невидимы через индекс. Гейт — шаг Validate // `reviews-index --check` на push в dev (см. комментарий в validate.yml, почему // не на issue-ветках: их переписывает конвейер из ветки по умолчанию, и его // коммиты индекс ветки знать не обязан). С #657 ветка задачи индекс не несёт // вовсе; свежесть dev держит `--commit-if-stale` слияния кандидата. test('#635 r3: свежесть индекса судится на dev, конвейер пересобирает индекс после своих ребейзов', () => { const validate = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8'); const step = validate.slice(validate.indexOf('id: reviews_index'), validate.indexOf('id: workflow_sync')); assert.match(step, /if: github\.event_name == 'push' && github\.ref == 'refs\/heads\/dev'/); assert.match(step, /run: node scripts\/reviews-index\.mjs --dir=docs\/reviews --check/); assert.match(validate, /REVIEWS_INDEX: \$\{\{ steps\.reviews_index\.outcome \}\}/); assert.match(validate, /\[ "\$REVIEWS_INDEX" = "skipped" \] \|\| check "индекс ревью совпадает с каталогом" "\$REVIEWS_INDEX"/); }); test('#635/#657 (1б): индекс пересобирается только коммитами, идущими в dev', () => { const wf = new URL('../.github/workflows/_process.yml', import.meta.url); const text = readFileSync(wf, 'utf8'); // Публикация документа: индекс — тем же коммитом, только если цель — dev // (ревью ТЗ). В ветку задачи — один документ. Скрипт — из снимка dev (#749). assert.match(text, /if \[ -f "\$doc" \] && \[ "\$target" = "dev" \]; then\n\s+node "\$TOOLS\/scripts\/reviews-index\.mjs" --dir=docs\/reviews\n\s+git add -- docs\/reviews\/INDEX\.md/); // Приведение ветки к dev индекс больше не коммитит: ветка задачи его не несёт. const rebase = text.slice(text.indexOf('- name: Привести ветку к dev'), text.indexOf('- name: Зафиксировать SHA материала ревью')); assert.doesNotMatch(rebase, /reviews-index\.mjs/, 'в ветке задачи индекс не пересобирается (#657)'); // Слияние кандидата в dev — единственная точка, где индекс задачи догоняет каталог. const merge = readFileSync(new URL('../scripts/merge-candidate.mjs', import.meta.url), 'utf8'); assert.match(merge, /REVIEWS_INDEX_SCRIPT, '--dir=docs\/reviews', '--commit-if-stale'/); }); // r1 #635 H1: индекс молчал о находках в живом формате заголовков и брал // счётчик из цитаты чужого документа. Строка «0 0 —» неотличима от «находок // не было» — поэтому оба регресса закреплены фикстурами с реальных документов. test('#635 r2: счётчик берётся из своего вердикта, а не из первого «High:» по тексту (CODE-REVIEW-594-r1)', () => { const doc = [ '# CODE-REVIEW-594-r1', 'ТЗ прошло ревью зелёным на r3 (SPEC-REVIEW-594-r3, High: 0, Medium: 0).', '## Находки', '### Medium (в скоупе задачи) — M1: AC7 не закрыт до конца — эталоны не приняты', 'текст', '### Medium (в скоупе задачи) — M2: отпечаток скриншотов не обновлён', '### Low — не блокирует', '## Вердикт', 'Жёлтый. High: 0, Medium: 3.', ].join('\n'); assert.deepEqual(parseCounts(doc), { high: 0, medium: 3 }); assert.equal(parseVerdict(doc), 'жёлтый'); assert.deepEqual(parseFindings(doc), ['AC7 не закрыт до конца — эталоны не приняты', 'отпечаток скриншотов не обновлён']); // Пересказ чужого раунда строчными в шапке не перебивает свой вердикт (CODE-REVIEW-152-r2). const retold = 'r1: вердикт красный, High: 1 · Medium: 2 (оба в скоупе)\n\n## Вердикт\n\n**Вердикт: зелёный · заход r2 · High: 0 · Medium: 0**\n'; assert.equal(parseVerdict(retold), 'зелёный'); assert.deepEqual(parseCounts(retold), { high: 0, medium: 0 }); // Без строки счётчика — по заголовкам: секция с нумерованными пунктами считается по пунктам. const headings = '### High (блокирует)\n\n**H1. один**\n\n**H2 — два**\n\n### Medium (в скоупе)\n\nтекст без номера\n\n### Low — нет\n'; assert.deepEqual(parseCounts(headings), { high: 2, medium: 1 }); }); test('#635 r2: находки читаются из живых форматов заголовков (CODE-REVIEW-639-r1, 637-r1, 162-r1, 141-r1)', () => { const doc = [ '### Medium (в скоупе — чинится в этой же ветке)', '', '**M1. Новая запись `smoke-links.mjs` для `smoke_space_settings_form.mjs`', 'ничего не связывает**', '### Medium (в скоупе задачи) — ложный «—» вместо настоящего «0 ч» в медианах', '## Находка 1 (High, в скоупе) — калибровка мимо своего этажа', 'тело', '### [High] Живой rubber-band превью не рисуется вовсе', '### Low L1 — ветки live/ambiguous без мутанта', '### Low — не найдено', '### High — нет', ].join('\n'); assert.deepEqual(parseFindings(doc), [ 'Новая запись smoke-links.mjs для smoke_space_settings_form.mjs ничего не связывает', 'ложный «—» вместо настоящего «0 ч» в медианах', 'калибровка мимо своего этажа', 'Живой rubber-band превью не рисуется вовсе', 'ветки live/ambiguous без мутанта', ]); assert.deepEqual(parseCounts(doc), { high: 2, medium: 2 }); }); test('#635 r2: файлы из находок попадают в индекс — «что находили по файлу X» отвечает grep', () => { const doc = '### Medium (в скоупе) — подпись переносится\n\nВ `src/form-kit.ts:412` и `test/form-kit.test.mjs` … `src/form-kit.ts:420-431`.\n\n## Что проверено\n\n`src/other.ts` не считается.\n'; assert.deepEqual(parseFiles(doc), ['src/form-kit.ts', 'test/form-kit.test.mjs']); const entry = indexEntry('CODE-REVIEW-594-r1.md', doc); const md = renderIndex({ entries: [entry] }); assert.match(md, /\| Находки \| Файлы \|/); assert.match(md, /`src\/form-kit\.ts` `test\/form-kit\.test\.mjs` \|$/m); assert.equal(md.split('\n').filter((l) => l.includes('form-kit')).length >= 1, true); }); // r2 #635 M1: у секции без заголовка находки брался хвост перенесённого // буллета («пусто). Не эскалирую…», CODE-REVIEW-485-r4). Абзац склеивается, // маркер снимается, «не найдено» и служебные скобки — не находка. test('#635 r2: первый абзац секции берётся целиком, а не хвост перенесённого буллета', () => { const doc = [ '### Low', '', '- Нет golden-сцены для радара (`find demo/golden` — по-прежнему', ' пусто). Не эскалирую третий раунд подряд.', '', '### Medium', '', '(унаследовано из r1/r2, перепроверено заново)', '', 'High не найдено. Medium вне скоупа не найдено — единственный M1 внутри.', '', '### Medium (в скоупе)', '', '**M1.** Токен `E2E_DISPATCH_TOKEN` scoped только', 'на соседний репозиторий.', ].join('\n'); const findings = parseFindings(doc); assert.equal(findings.length, 2); assert.match(findings[0], /^Нет golden-сцены для радара \(find demo\/golden — по-прежнему пусто\)\. Не эскалирую/); assert.equal(findings[1], 'Токен E2E_DISPATCH_TOKEN scoped только на соседний репозиторий'); }); test('#635 r2: --commit-if-stale пересобирает индекс и коммитит его только при расхождении', () => { const repo = mkdtempSync(join(tmpdir(), 'hp-reviews-commit-')); const git = (args) => spawnSync('git', args, { cwd: repo, encoding: 'utf8' }); try { git(['init', '-q']); const dir = join(repo, 'docs', 'reviews'); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, 'CODE-REVIEW-1-r1.md'), 'Вердикт: **зелёный** · High: 0 · Medium: 0\n'); writeFileSync(join(dir, INDEX_FILE), buildIndex(dir)); git(['add', '-A']); git(['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'base']); const base = git(['rev-parse', 'HEAD']).stdout.trim(); const fresh = commitIfStale({ dir, issue: '635', git }); assert.deepEqual(fresh, { changed: false, sha: null }); assert.equal(git(['rev-parse', 'HEAD']).stdout.trim(), base, 'свежий индекс — коммита нет'); // «Ребейз» принёс новый документ: индекс устарел. writeFileSync(join(dir, 'CODE-REVIEW-2-r1.md'), 'Вердикт: **жёлтый** · High: 0 · Medium: 1\n### M1 — x\n'); git(['add', '-A']); git(['-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-q', '-m', 'rebased doc']); const stale = commitIfStale({ dir, issue: '635', git }); assert.equal(stale.changed, true); assert.equal(stale.sha, git(['rev-parse', 'HEAD']).stdout.trim()); assert.equal(git(['status', '--porcelain']).stdout.trim(), '', 'рабочая копия чистая'); const message = git(['log', '-1', '--format=%B']).stdout; assert.match(message, /^docs\(reviews\): индекс после сдвига каталога \(#635\)/); assert.match(message, /Issue: #635\nUser-Visible: no/); assert.deepEqual(git(['diff', '--name-only', 'HEAD~1', 'HEAD']).stdout.trim().split('\n'), ['docs/reviews/INDEX.md']); assert.match(readFileSync(join(dir, INDEX_FILE), 'utf8'), /CODE-REVIEW-2-r1\.md/); assert.ok(existsSync(join(dir, INDEX_FILE))); } finally { rmSync(repo, { recursive: true, force: true }); } });