From 9bc23f987188febc9b3e4e2346eff09fe79f4612 Mon Sep 17 00:00:00 2001 From: Sergey Matyunin Date: Sun, 27 Sep 2026 09:42:21 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B8=D0=BD=D0=B4=D0=B5=D0=BA=D1=81?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=80=D0=B5=D0=B2?= =?UTF-8?q?=D1=8C=D1=8E=20=D1=81=D1=82=D0=B0=D0=B1=D0=B8=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B5=D0=BB=D0=B8=D0=B7=D0=B0=20(#6?= =?UTF-8?q?70)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue: #670 User-Visible: no --- .github/workflows/release-review.yml | 2 +- scripts/mutation-registry.mjs | 40 ++++++++++++++++++++++++++++ scripts/reviews-index.mjs | 34 ++++++++++++++++++++--- test/release-review.test.mjs | 2 +- test/reviews-index.test.mjs | 37 ++++++++++++++++++++++--- 5 files changed, 107 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release-review.yml b/.github/workflows/release-review.yml index 1e5f0b40..ad131044 100644 --- a/.github/workflows/release-review.yml +++ b/.github/workflows/release-review.yml @@ -307,7 +307,7 @@ jobs: "$TAG" "$CANDIDATE" "${BASE:-—}" "${ISSUES:-—}" "$RUN_URL" printf '```\n' } > "$DOC" - node scripts/reviews-index.mjs --dir=docs/reviews + node scripts/reviews-index.mjs --dir=docs/reviews --strict git add -- "$DOC" docs/reviews/INDEX.md git diff --cached --name-only | node scripts/review-doc-guard.mjs git -c user.name="claude[bot]" \ diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index 9fb5a9c7..9dfba4b4 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -9832,6 +9832,46 @@ const MUTANT_DEFINITIONS = [ replace: " const names = readdirSync(dir).filter((name) => name.endsWith('.md')).sort(); // mutant: indexes itself", }], }, + { + id: 'reviews-index-release-name-unsupported', + guard: 'node --test --test-name-pattern="#635 индекс покрывает|#670" test/reviews-index.test.mjs', + because: '#670: the first line review document must be indexed instead of making the next dev Validate red', + patches: [{ + file: 'scripts/reviews-index.mjs', + find: " if (release) return { stage: 'release', issue: null, round: null, suffix: null, tag: release[1] };", + replace: " if (false && release) return { stage: 'release', issue: null, round: null, suffix: null, tag: release[1] }; // mutant: release review is unknown", + }], + }, + { + id: 'reviews-index-release-counts-ignored', + guard: 'node --test --test-name-pattern="#635 счётчики|#635 индекс покрывает" test/reviews-index.test.mjs', + because: '#670: release reviews use the mandated `Итог: High N · Medium N` summary without colons', + patches: [{ + file: 'scripts/reviews-index.mjs', + find: ' if (release) return release;\n for (const scope of [verdictLine(text, true), verdictSection(text), verdictLine(text)]) {', + replace: ' void release; // mutant: ignore the release summary\n for (const scope of [verdictLine(text, true), verdictSection(text), verdictLine(text)]) {', + }], + }, + { + id: 'reviews-index-strict-cli-disabled', + guard: 'node --test --test-name-pattern="#670" test/reviews-index.test.mjs', + because: '#670: strict publication must reject a new unknown document before it writes an incomplete index', + patches: [{ + file: 'scripts/reviews-index.mjs', + find: " if (process.argv.includes('--strict')) assertAllDocumentsIndexed(collected);", + replace: " if (false && process.argv.includes('--strict')) assertAllDocumentsIndexed(collected); // mutant: strict is disabled", + }], + }, + { + id: 'release-review-index-without-strict', + guard: 'node --test --test-name-pattern="#638" test/release-review.test.mjs', + because: '#670: the publisher itself must opt into strict indexing so a future filename drift fails at its source', + patches: [{ + file: '.github/workflows/release-review.yml', + find: 'node scripts/reviews-index.mjs --dir=docs/reviews --strict', + replace: 'node scripts/reviews-index.mjs --dir=docs/reviews', + }], + }, { id: 'reviews-index-verdict-substring', guard: 'node --test --test-name-pattern="#635 вердикт" test/reviews-index.test.mjs', diff --git a/scripts/reviews-index.mjs b/scripts/reviews-index.mjs index 84d723da..591c4338 100644 --- a/scripts/reviews-index.mjs +++ b/scripts/reviews-index.mjs @@ -8,11 +8,13 @@ // заголовки находок. Он генерируется (класс C), а не пишется руками, и // пересобирается конвейером после публикации каждого документа ревью. // -// node scripts/reviews-index.mjs [--dir=docs/reviews] [--output=docs/reviews/INDEX.md] [--check] +// 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), поэтому @@ -25,6 +27,7 @@ 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: 'жёлтый', @@ -38,6 +41,8 @@ const VERDICT_OWN_LINE_RE = /^[ \t]*(?:[-*]\s*)?\**(?:Вердикт|Verdict)[^\ /** Разобрать имя документа: этап, 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 { @@ -99,6 +104,11 @@ const countsIn = (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 и телом секции. Тело — строки до * следующего заголовка того же или более высокого уровня. @@ -175,6 +185,8 @@ function numberedItems(body) { * документа («ТЗ прошло зелёным на 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; @@ -281,11 +293,22 @@ export function collectEntries(dir, read = (name) => readFileSync(join(dir, 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 = [] }) { + const releaseDocs = entries.filter((entry) => entry.stage === 'release').sort((a, b) => { + const av = a.tag.slice(1).split('.').map(Number); + const bv = b.tag.slice(1).split('.').map(Number); + for (let i = 0; i < 3; i += 1) if (av[i] !== bv[i]) return bv[i] - av[i]; + return 0; + }); const byIssue = new Map(); - for (const entry of entries) { + for (const entry of entries.filter((item) => item.stage !== 'release')) { const list = byIssue.get(entry.issue) || []; list.push(entry); byIssue.set(entry.issue, list); @@ -298,6 +321,9 @@ export function renderIndex({ entries, skipped = [] }) { lines.push(''); lines.push('| Issue | Документ | Этап · раунд | Вердикт | H | M | Находки | Файлы |'); lines.push('|---|---|---|---|---:|---:|---|---|'); + for (const doc of releaseDocs) { + lines.push(`| линия ${doc.tag} | [${doc.name}](${doc.name}) | ревью линии · — | ${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) { @@ -353,7 +379,9 @@ if (isMainModule(import.meta.url)) { console.log(changed ? `${output} пересобран и закоммичен: ${sha}` : `${output} свеж — коммит не нужен`); process.exit(0); } - const markdown = buildIndex(dir); + 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) { diff --git a/test/release-review.test.mjs b/test/release-review.test.mjs index c02aab19..af8b0749 100644 --- a/test/release-review.test.mjs +++ b/test/release-review.test.mjs @@ -84,7 +84,7 @@ test('#638: только ручной/вызванный запуск на dev, const publish = jobBlock('publish'); assert.match(publish, /secrets\.HP_PROCESS_TOKEN/); assert.match(publish, /review-doc-guard\.mjs/, 'в dev уходит только docs\/reviews'); - assert.match(publish, /reviews-index\.mjs --dir=docs\/reviews/, 'индекс тем же коммитом'); + assert.match(publish, /reviews-index\.mjs --dir=docs\/reviews --strict/, 'индекс тем же коммитом и неизвестное имя сразу блокирует публикацию'); assert.match(publish, /Issue: #638\n\s+User-Visible: no/, 'трейлеры провенанса'); }); diff --git a/test/reviews-index.test.mjs b/test/reviews-index.test.mjs index e30a3857..4ceafe87 100644 --- a/test/reviews-index.test.mjs +++ b/test/reviews-index.test.mjs @@ -7,13 +7,17 @@ import { fileURLToPath } from 'node:url'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { - INDEX_FILE, buildIndex, collectEntries, commitIfStale, indexEntry, parseCounts, parseDocName, parseFiles, parseFindings, parseVerdict, renderIndex, + 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); }); @@ -32,6 +36,7 @@ test('#635 вердикт: явная строка, раздел «Вердик 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([ @@ -59,14 +64,17 @@ test('#635 индекс покрывает каталог целиком, дет 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, 4); + 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, /Документов: 4, issue: 2/); + 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)', @@ -76,6 +84,7 @@ test('#635 индекс покрывает каталог целиком, дет ], 'новые 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); @@ -85,6 +94,28 @@ test('#635 индекс покрывает каталог целиком, дет } }); +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(', ')}`);