mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-29 03:09:36 +00:00
fix: индексировать ревью стабильного релиза (#670)
Issue: #670 User-Visible: no
This commit is contained in:
@@ -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]" \
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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/, 'трейлеры провенанса');
|
||||
});
|
||||
|
||||
|
||||
@@ -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(', ')}`);
|
||||
|
||||
Reference in New Issue
Block a user