fix: индексировать ревью стабильного релиза (#670)

Issue: #670
User-Visible: no
This commit is contained in:
Sergey Matyunin
2026-09-27 09:43:51 +03:00
parent e45dcad47a
commit 9bc23f9871
5 changed files with 107 additions and 8 deletions
+1 -1
View File
@@ -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]" \
+40
View File
@@ -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',
+31 -3
View File
@@ -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) {
+1 -1
View File
@@ -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/, 'трейлеры провенанса');
});
+34 -3
View File
@@ -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(', ')}`);