import test from 'node:test'; import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { ANCHOR_MARKER, REVIEW_DOC_ALLOWLIST, anchorLiveness, REVIEW_HEADER_LINES, citedMaterialShas, danglingMaterialRefusal, materialAnchorBlock, materialAnchorsFrom, parseSpecList, pathsOutsideAllowlist, reviewDocPushRefusal, withMaterialAnchors, attemptFromRounds, blockingFromDocs, isBlockingVerdict, reviewCounters, reviewRoundsFromFiles, verdictDeclaration, commentCounters, stageVerdictComments, anchorTreeFrom, anchorVerdictFrom, reusableGreenVerdict, anchorIssueBodyFrom, issueBodyChanged, issueBodyDigest, normalizeIssueBody, } from '../scripts/review-doc-guard.mjs'; // #365. 28.08 шаг публикации ревью-дока запушил в dev коммит bb2919f с тридцатью // файлами вместо одного markdown: откатил отревьюженную реализацию #359, вернул // старые чанки, оставил в dist/ двойной набор. dev держал откаченное дерево три // часа. Сообщение коммита было невинным — «docs: review document for #359», — и // от рутины инцидент отличался только диффом. Релиз собирается из dev. test('чистая публикация проходит (#365 AC1)', () => { assert.equal(reviewDocPushRefusal(['docs/reviews/CODE-REVIEW-359-r1.md']), null); assert.equal(reviewDocPushRefusal([ 'docs/reviews/SPEC-REVIEW-1-r1.md', 'docs/reviews/SPEC-REVIEW-1-r2.md', ]), null); }); test('посторонний путь отменяет пуш и называет файлы (#365 AC2)', () => { const refusal = reviewDocPushRefusal([ 'docs/reviews/CODE-REVIEW-359-r1.md', 'src/houseplan-card.ts', 'dist/houseplan-card.js', ]); assert.match(refusal, /задевает 2 путь\(ей\)/); assert.match(refusal, /dist\/houseplan-card\.js/); assert.match(refusal, /src\/houseplan-card\.ts/); // Причина названа, а не только факт: без неё следующий читатель решит, что // проверка придирается, и снимет её. assert.match(refusal, /bb2919f/); }); test('пустой дифф — тоже отказ, а не тихий успех (#365)', () => { // Публиковать нечего означает, что что-то пошло не так раньше. Прежняя // редакция шага в таком случае выходила с нулём, и вердикт ревью оставался // без артефакта (#171). assert.match(reviewDocPushRefusal([]), /публиковать нечего/); assert.match(reviewDocPushRefusal(['', ' ']), /публиковать нечего/); }); test('соседний каталог с похожим именем не считается разрешённым (#365)', () => { // Сравнение по префиксу каталога со слэшем: docs/reviews-old подстрокой не // притворяется. assert.deepEqual( pathsOutsideAllowlist(['docs/reviews-old/x.md', 'docs/reviews/y.md']), ['docs/reviews-old/x.md'], ); assert.deepEqual(pathsOutsideAllowlist(['docs/reviewsx.md']), ['docs/reviewsx.md']); }); test('allowlist задаётся снаружи и по умолчанию только docs/reviews (#365)', () => { assert.deepEqual(REVIEW_DOC_ALLOWLIST, ['docs/reviews/']); assert.equal(reviewDocPushRefusal(['docs/specs/1.md'], ['docs/specs']), null); assert.match(reviewDocPushRefusal(['docs/specs/1.md']), /docs\/specs\/1\.md/); }); test('шаг публикации в конвейере проверяет и индекс, и то, что уедет (#365 AC4)', () => { const workflow = readFileSync( new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8', ); const step = workflow.slice( workflow.indexOf('- name: Опубликовать документ ревью'), workflow.indexOf('- name: Решение по вердикту'), ); assert.ok(step.length > 500, 'шаг публикации не найден'); // Два рубежа: что проиндексировано и что пуш добавит в ветку. Расходились они // именно тогда, когда база оказывалась не той. assert.equal( (step.match(/git diff --cached --name-only \| node "\$TOOLS\/scripts\/review-doc-guard\.mjs"/g) || []).length, 1, 'индекс проверяется один раз, перед коммитом', ); // Дважды: push делается из двух мест — сразу и после ребейза при гонке. Одна // проверка на два пути означала бы, что второй путь не проверен вовсе, а // именно он срабатывает, когда dev ушёл вперёд — то есть в тех самых // условиях, при которых случился bb2919f. assert.equal( (step.match(/git diff --name-only "origin\/\$target\.\.\.HEAD" \| node "\$TOOLS\/scripts\/review-doc-guard\.mjs"/g) || []).length, 2, 'диапазон проверяется перед каждым push', ); // Свежая база вместо той, что лежала здесь сорок минут назад. assert.match(step, /git reset -q --hard "origin\/\$target"/); // Форс-пуш запрещён: ветка двигается только вперёд. assert.equal(/--force/.test(step), false, 'в публикации ревью-дока не должно быть force-push'); // Индексируется один путь, а не каталог. assert.match(step, /git add -- "\$doc"/); }); // --- материал раунда обязан быть достижим (#413) ---------------------------- test('SHA из шапки извлекаются, а из прозы — нет (#413)', () => { const doc = [ '# SPEC-REVIEW-403-r2', '', '## Скоуп', '', '- Материал: спец-файл на `HEAD = 83005c3c` (ветка `issue/403-x`,', ' коммит «docs: revise area relocation safety spec»)', '- Ревизия: 2', ].join('\n') + '\n'.repeat(30) + 'Так коммит bb2919f7 откатил dev на три часа.\n'; const cited = citedMaterialShas(doc); assert.deepEqual(cited.map((item) => item.sha), ['83005c3c']); assert.equal(cited[0].line, 5); }); test('не-SHA в шапку не попадают: цвета, sha256, номера (#413)', () => { const doc = [ '- Материал: коммит `cbf5cc1b`, цвет #607d8bff, прогон 20260901,', ' imageSha256 `9119ab87502038f787529f621c39e1e0d01f3bc3b0289051c3791a1886e97a6b`,', ' ссылка sha256-abc1234def', ].join('\n'); assert.deepEqual(citedMaterialShas(doc).map((item) => item.sha), ['cbf5cc1b']); }); test('недостижимый SHA останавливает раунд и объясняет, почему (#413)', () => { const doc = '- Материал: спец-файл на `HEAD = 83005c3c`\n'; const refusal = danglingMaterialRefusal(doc, () => new Map([['83005c3c', null]])); assert.match(refusal, /83005c3c/); assert.match(refusal, /не достижим ни из одной ссылки origin/); // Отказ обязан называть и команду из канона, и способ не повторить: // на #403 ревьюер снял HEAD до ребейза и не сверился перед выводом. assert.match(refusal, /git diff/); assert.match(refusal, /git rev-parse HEAD/); }); test('достижимый SHA раунд не задерживает (#413)', () => { const doc = '- Материал: коммит `cbf5cc1b`\n'; const resolve = () => new Map([['cbf5cc1b', 'refs/remotes/origin/dev']]); assert.equal(danglingMaterialRefusal(doc, resolve), null); }); test('шапка без объявления материала не судится (#413)', () => { // Часть документов материал не объявляет вовсе — по корпусу таких 146 из 555. // Требовать объявление — отдельное решение о каноне, а не дело гейта. assert.equal(danglingMaterialRefusal('# CODE-REVIEW-1-r1\n\nтекст\n', () => new Map()), null); assert.deepEqual(citedMaterialShas('# CODE-REVIEW-1-r1\n\nтекст\n'), []); }); // --- якоря, переживающие ребейз (#414) ------------------------------------- test('блок якорей содержит исполнимые команды, а не описание (#414)', () => { const block = materialAnchorBlock({ sha: '94502d3d67cacf85bdb9f69cd511b342989891fd', tree: '3fc651fcb868eefa28755d01ec2b9377598dcb27', branch: 'issue/403-area-relocation-safety', specs: [{ blob: '56a92e12dedc8fa541537ae5908dc6f1dfab43e8', path: 'docs/specs/403-area-relocation-safety.md', }], }); // Отчёт обязан быть исполняемым: на #403 канонная команда не работала, и // следующий раунд восстанавливал коммит по содержимому диффа руками. assert.match(block, /git log --all --find-object=56a92e12dedc8fa541537ae5908dc6f1dfab43e8/); assert.match(block, /git log --all --format='%H %T' \| grep 3fc651fcb868/); assert.match(block, /ребейз его осиротит/, 'блок обязан объяснять, зачем он нужен'); assert.match(block, /material-anchors: сгенерировано конвейером/); }); test('без ветки задачи блок честно говорит, что якорей нет (#414)', () => { const block = materialAnchorBlock({ branch: '', sha: '', tree: '', specs: [] }); assert.match(block, /Якоря снять не удалось/); }); test('повторная приписка заменяет блок, а не копит его (#414)', () => { const anchors = { sha: 'a'.repeat(40), tree: 'b'.repeat(40), branch: 'dev', specs: [] }; const once = withMaterialAnchors('# отчёт\n\nтекст\n', anchors); const twice = withMaterialAnchors(once, anchors); assert.equal(twice.split(ANCHOR_MARKER).length - 1, 1, 'маркер обязан быть один'); assert.match(twice, /# отчёт/); }); test('#765: повторная приписка идемпотентна — разделитель перед блоком не копится', () => { const anchors = { sha: 'a'.repeat(40), tree: 'b'.repeat(40), branch: 'dev', specs: [] }; const once = withMaterialAnchors('# отчёт\n\nтекст\n', anchors); const thrice = withMaterialAnchors(withMaterialAnchors(once, anchors), anchors); assert.equal(thrice, once); assert.equal(thrice.match(/^---$/gm).length, 1, 'один разделитель'); // Разделитель самого документа — не наш: он остаётся. const own = withMaterialAnchors('# отчёт\n\n---\n\nвывод\n', anchors); assert.equal(withMaterialAnchors(own, anchors), own); assert.match(own, /---\n\nвывод\n\n---\n\n', 'Undocumented', '', '-a']) { assert.equal(line(materialAnchorBlock({ ...base, route: 'reclassify', criterion })), '- Вердикт конвейера: `yellow` · High 0 · маршрут `reclassify`', JSON.stringify(criterion)); } // anchorVerdictFrom читает старую и новую строку одинаково. for (const verdict of ['green', 'yellow', 'red']) { for (const high of [0, 2]) { const old = materialAnchorBlock({ ...base, verdict, high }); const routed = materialAnchorBlock({ ...base, verdict, high, route: 'reclassify', criterion: 'surfaces' }); assert.deepEqual(anchorVerdictFrom(routed), anchorVerdictFrom(old)); assert.deepEqual(anchorVerdictFrom(routed), { verdict, high }); } } // reusableGreenVerdict на новом якоре — как на старом. const green = (anchors) => docWith('CODE-REVIEW-7-r2.md', { tree: TREE_A, verdict: 'green', high: 0, ...anchors }); const expected = { doc: 'CODE-REVIEW-7-r2.md', round: 2, tree: TREE_A, verdict: 'green' }; assert.deepEqual(reusableGreenVerdict([green({})], () => false), expected); assert.deepEqual(reusableGreenVerdict([green({ route: 'fix' })], () => false), expected); assert.equal(reusableGreenVerdict([green({ route: 'fix' })], () => true), null, 'дерево отличается — как раньше'); assert.equal(reusableGreenVerdict([docWith('CODE-REVIEW-7-r2.md', { tree: TREE_A, verdict: 'yellow', high: 0, route: 'reclassify', criterion: 'undocumented' })], () => false), null); }); test('#726 AC6: CLI --anchor дописывает маршрут из --route и --criterion', (t) => { const dir = mkdtempSync(join(tmpdir(), 'hp-726-anchor-')); t.after(() => rmSync(dir, { recursive: true, force: true })); const doc = join(dir, 'CODE-REVIEW-7-r1.md'); const anchor = (...flags) => { writeFileSync(doc, '# CODE-REVIEW-7-r1\n'); const r = spawnSync(process.execPath, [fileURLToPath(new URL('../scripts/review-doc-guard.mjs', import.meta.url)), `--anchor=${doc}`, `--sha=${'c'.repeat(40)}`, `--tree=${TREE_A}`, '--branch=issue/7-x', '--verdict=yellow', '--high=0', ...flags], { encoding: 'utf8' }); assert.equal(r.status, 0, r.stderr); return readFileSync(doc, 'utf8').split('\n').find((l) => l.startsWith('- Вердикт конвейера:')); }; assert.equal(anchor('--route=reclassify', '--criterion=undocumented'), '- Вердикт конвейера: `yellow` · High 0 · маршрут `reclassify` (критерий `undocumented`)'); assert.equal(anchor('--route=', '--criterion='), '- Вердикт конвейера: `yellow` · High 0', 'пустые флаги — вердикт до #726'); assert.equal(anchor(), '- Вердикт конвейера: `yellow` · High 0'); }); test('конвейер: посторонняя метка не входит в concurrency, guard читает текущие метки (#499)', () => { const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); // Concurrency — на job, не на workflow: иначе любой `labeled` вытеснял ожидающий S7. const head = workflow.slice(0, workflow.indexOf('\njobs:')); assert.doesNotMatch(head, /^concurrency:/m, 'concurrency на уровне workflow снова пустит в группу все метки'); const guard = workflow.slice(workflow.indexOf('\n guard:'), workflow.indexOf('\n prepare:')); assert.match(guard, /if: github\.event\.label\.name == 'S4-spec-review' \|\| github\.event\.label\.name == 'S7-code-review'/); assert.match(guard, /concurrency:\n\s+group: process-issue-\$\{\{ github\.event\.issue\.number \}\}/); for (const [job, next] of [['prepare', 'model_review'], ['model_review', 'integrate'], ['integrate', null]]) { const start = workflow.indexOf(`\n ${job}:`); const end = next ? workflow.indexOf(`\n ${next}:`) : workflow.length; assert.match(workflow.slice(start, end), /concurrency:\n\s+group: process-issue-\$\{\{ github\.event\.issue\.number \}\}/, `${job} сериализован по issue`); } // Состояние читается текущее, не из снимка события. assert.match(guard, /gh issue view "\$NUM" --repo "\$REPO" --json labels/); assert.doesNotMatch(guard, /contains\(github\.event\.issue\.labels/, 'снимок события больше не источник меток'); assert.match(guard, /запрос отозван/); }); test('конвейер: зелёный вердикт применяется повторно без модели, вердикт пишется в якоря (#499)', () => { const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); assert.match(workflow, /node "\$TOOLS\/scripts\/review-doc-guard\.mjs" --reuse --marker=CODE-REVIEW --num="\$NUM" --head=HEAD/, 'reuse решает скрипт снимка dev, не ветки (#765)'); const modelJob = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n integrate:')); assert.match(modelJob, /if: needs\.prepare\.outputs\.proceed == 'true' && needs\.prepare\.outputs\.reuse != 'true'/, 'при повторном применении вся стадия модели пропускается'); const publish = workflow.slice(workflow.indexOf('- name: Опубликовать документ ревью'), workflow.indexOf('- name: Решение по вердикту')); assert.match(publish, /--verdict="\$verdict" --high="\$high"/); const decide = workflow.slice(workflow.indexOf('- name: Решение по вердикту'), workflow.indexOf('- name: dev ушёл вперёд')); assert.match(decide, /if \[ "\$REUSE" = "true" \]; then\n\s+(#[^\n]*\n\s+)*verdict=green; high=0/); // Ревьюер привязан к SHA материала — сам подтягивать новее не должен. assert.match(workflow, /Материал ревью — ровно\s+`\$\{\{ needs\.prepare\.outputs\.material_sha \}\}`/); }); test('#510 AC2: конвейер запускает Validate с мутантами на материале и не ревьюит красный', () => { const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; }; const material = at(' - name: Зафиксировать SHA материала ревью\n'); const reuse = at(' - name: "Зелёный вердикт прошлого захода применим без ревью (#499)"\n'); const gate = at(' - name: Validate на материале\n'); assert.ok(material < reuse && reuse < gate, 'gate читает steps.reuse.outputs — стоит после шага reuse (ревью ТЗ r1)'); const back = at(' - name: Validate красный — вернуть автору без ревью\n'); const modelJob = at('\n model_review:\n'); const deps = at(' - name: Установить зависимости\n'); const review = at(' - name: Review\n'); assert.ok(material < gate && gate < back && back < modelJob && modelJob < deps && deps < review, 'гейт закончен в отдельной стадии до установки зависимостей/ревью'); const gateStep = workflow.slice(gate, back); assert.match(gateStep, /node "\$TOOLS\/scripts\/validate-gate\.mjs" --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA"/); assert.match(gateStep, /if \[ "\$STAGE" != "code" \] \|\| \[ "\$REUSE" = "true" \]/, 'этап spec и reuse гейт не проходят'); assert.match(gateStep, /SHA: \$\{\{ steps\.material\.outputs\.sha \}\}/, 'проверяется именно материал'); // skip-ветка (spec/reuse) даёт proceed=true: ревью идёт, возврата S7→S6 нет (ревью ТЗ r2) assert.match(gateStep, /\{ echo 'proceed=true'; echo 'result=skipped'; \}/, 'skipped = proceed'); assert.doesNotMatch(workflow.slice(modelJob), /steps\.gate\.outputs/, 'следующие jobs не читают локальные outputs prepare'); const backStep = workflow.slice(back, deps); // #636: третий исход гейта — pending (Validate идёт); возврат автору только на явном false assert.match(backStep, /if: steps\.rebase\.outputs\.conflict != 'true' && steps\.gate\.outputs\.proceed == 'false'/); assert.match(backStep, /--add-label S6-in-progress --remove-label S7-code-review/); assert.match(backStep, /цикл ревью не израсходован/); assert.match(workflow.slice(modelJob, deps), /if: needs\.prepare\.outputs\.proceed == 'true'/, 'красный prepare вообще не запускает модель'); const integrate = workflow.slice(at('\n integrate:\n')); assert.match(integrate, /PROCEED: \$\{\{ needs\.prepare\.outputs\.proceed \}\}/); assert.match(integrate, /if \[ "\$PROCEED" != "true" \]; then[\s\S]*echo "proceed=false"/, 'интеграция не применяется после красного gate'); }); test('#515: якоря материала снимаются ПОСЛЕ ребейза конвейером и публикуются из шага material', () => { const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; }; const rebase = at(' - name: Привести ветку к dev\n'); const material = at(' - name: Зафиксировать SHA материала ревью\n'); const reuse = at(' - name: "Зелёный вердикт прошлого захода применим без ревью (#499)"\n'); assert.ok(rebase < material && material < reuse, 'material стоит после ребейза и до reuse'); const materialStep = workflow.slice(material, reuse); assert.match(materialStep, /echo "tree=\$\(git rev-parse 'HEAD\^\{tree\}'\)" >> "\$GITHUB_OUTPUT"/, 'дерево — из шага material'); assert.match(materialStep, /echo "specs=\$specs" >> "\$GITHUB_OUTPUT"/, 'ТЗ — из шага material'); const publish = at(' - name: Опубликовать документ ревью\n'); const publishStep = workflow.slice(publish, at(' - name: "Материал раунда воспроизводим (#413)"\n')); for (const name of ['SHA', 'TREE', 'SPECS']) { assert.match(publishStep, new RegExp(`MATERIAL_${name}: \\$\\{\\{ needs\\.prepare\\.outputs\\.material_${name.toLowerCase()} \\}\\}`), `MATERIAL_${name} из prepare`); } // до-ребейзные якоря из шага branch никем не читаются: после force-push они мертвы (#508 r1–r3) assert.doesNotMatch(workflow, /steps\.branch\.outputs\.(sha|tree|specs)/, 'якоря из шага branch — осиротевшие после ребейза'); }); // --- ТЗ живёт в теле issue: хеш тела как якорь материала (#517) -------------- const BODY_A = issueBodyDigest('## ТЗ\n\n- AC1. Как было'); const BODY_B = issueBodyDigest('## ТЗ\n\n- AC1. Как стало'); test('#517 AC1: нормализация тела гасит правки, которых в тексте нет', () => { const canonical = '## ТЗ\n\n- AC1. Строка\n- AC2. Вторая'; assert.equal(normalizeIssueBody('## ТЗ\r\n\r\n- AC1. Строка \r\n- AC2. Вторая\n\n\n'), canonical); assert.equal(issueBodyDigest('## ТЗ\r\n\r\n- AC1. Строка \n- AC2. Вторая'), issueBodyDigest(canonical)); // Содержательный пробел внутри строки значим: в ТЗ есть таблицы и код. assert.notEqual(issueBodyDigest('| AC1 | текст |'), issueBodyDigest('| AC1 | текст |')); assert.match(BODY_A, /^[0-9a-f]{64}$/); assert.notEqual(BODY_A, BODY_B); }); test('#517 AC1: блок якорей несёт хеш тела и читается обратно', () => { const block = materialAnchorBlock({ tree: TREE_A, issueBody: BODY_A, verdict: 'green', high: 0 }); assert.match(block, new RegExp(`- Тело issue: \`${BODY_A}\``)); assert.equal(anchorIssueBodyFrom(block), BODY_A); // Старые документы без строки читаются как раньше, а не как «хеш пустой». assert.equal(anchorIssueBodyFrom(materialAnchorBlock({ tree: TREE_A })), null); assert.doesNotMatch(materialAnchorBlock({ tree: TREE_A }), /Тело issue/); // Проза вне машинного блока записью не считается. assert.equal(anchorIssueBodyFrom(`Тело issue: \`${BODY_A}\`\nбез маркера`), null); // Ветки задачи нет, но тело есть — материал воспроизводим, отговорки нет. assert.doesNotMatch(materialAnchorBlock({ issueBody: BODY_A }), /Якоря снять не удалось/); assert.match(materialAnchorBlock({}), /Якоря снять не удалось/); }); test('#517 AC2: правка тела после зелёного ревью ТЗ — находка; неизменённое молчит', () => { const green = docWith('SPEC-REVIEW-9-r2.md', { tree: TREE_A, verdict: 'green', high: 0, issueBody: BODY_A }); const yellow = docWith('SPEC-REVIEW-9-r1.md', { tree: TREE_B, verdict: 'yellow', high: 0, issueBody: BODY_B }); assert.equal(issueBodyChanged([yellow, green], BODY_A), null, 'текст тот же — молчим'); const changed = issueBodyChanged([yellow, green], BODY_B); assert.equal(changed.doc, 'SPEC-REVIEW-9-r2.md'); assert.equal(changed.recorded, BODY_A); assert.equal(changed.current, BODY_B); // Судится последний ЗЕЛЁНЫЙ, а не последний вообще. const red = docWith('SPEC-REVIEW-9-r3.md', { tree: TREE_A, verdict: 'red', high: 1, issueBody: BODY_B }); assert.equal(issueBodyChanged([green, red], BODY_A), null); // Задачи до перехода: зелёного документа нет либо в нём нет записи — не находка. assert.equal(issueBodyChanged([yellow], BODY_B), null); assert.equal(issueBodyChanged([docWith('SPEC-REVIEW-9-r1.md', { tree: TREE_A, verdict: 'green', high: 0 })], BODY_A), null); assert.equal(issueBodyChanged([green], ''), null, 'хеша нет — сравнивать не с чем'); }); test('#517 AC6: reuse не применяет зелёный вердикт, если тело issue изменилось', () => { const green = docWith('CODE-REVIEW-9-r2.md', { tree: TREE_A, verdict: 'green', high: 0, issueBody: BODY_A }); assert.ok(reusableGreenVerdict([green], () => false, BODY_A), 'тело то же — вердикт применим'); assert.equal(reusableGreenVerdict([green], () => false, BODY_B), null, 'тело менялось — нужен разбор'); // Документ без записи (весь бэклог до перехода) судится по дереву, как раньше. const legacy = docWith('CODE-REVIEW-9-r2.md', { tree: TREE_A, verdict: 'green', high: 0 }); assert.ok(reusableGreenVerdict([legacy], () => false, BODY_B)); assert.ok(reusableGreenVerdict([green], () => false), 'хеш не передан — прежнее поведение'); }); test('#517: конвейер снимает хеш тела на материале и передаёт его в якоря, reuse и ревьюеру', () => { const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; }; const material = at(' - name: Зафиксировать SHA материала ревью\n'); const reuse = at(' - name: "Зелёный вердикт прошлого захода применим без ревью (#499)"\n'); const specBody = at(' - name: "ТЗ менялось после зелёного ревью ТЗ (#517)"\n'); const review = at(' - name: Review\n'); assert.ok(material < reuse && reuse < specBody && specBody < review, 'хеш снят до reuse, находка — до модели'); const materialStep = workflow.slice(material, reuse); assert.match(materialStep, /gh issue view "\$NUM" --repo "\$REPO" --json body/, 'тело читается в прогоне, не из события'); assert.match(materialStep, /echo "issue_body=\$digest" >> "\$GITHUB_OUTPUT"/); assert.match(workflow.slice(reuse, specBody), /--issue-body="\$\{ISSUE_BODY\}"/, 'reuse учитывает тело (AC6)'); assert.match(workflow, /--issue-body="\$MATERIAL_ISSUE_BODY"/, 'якорь попадает в документ'); assert.match(workflow, /needs\.prepare\.outputs\.spec_body_changed == 'true' &&/, 'находка уходит в промпт ревьюера'); }); test('#517 AC4: документы процесса не требуют файла ТЗ, индекс docs/specs удалён', () => { const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8'); const process = read('PROCESS.md'); // Цепочка §7.1 называет тело issue, а не файл. assert.match(process, /↔ ТЗ\s+тело issue, раздел `## ТЗ`/); assert.doesNotMatch(process, /Артефакт:\*\* `docs\/specs\//, 'файл ТЗ больше не артефакт этапа S3'); // Гейт судит текст, а не наличие файла. assert.match(process, /у класса A есть ТЗ: раздел `## ТЗ` или хотя бы один `AC1`/); const agents = read('AGENTS.md'); assert.match(agents, /spec lives in the \*\*issue body\*\*/); assert.match(agents, /`docs\/specs\/` is an archive/); // README архива: ни одной строки индекса «| [#NN](…) | […](….md) |». const readme = read('docs/specs/README.md'); assert.match(readme, /архив/i); assert.equal((readme.match(/^\| \[#\d+\]/gm) || []).length, 0, 'таблица-индекс удалена'); assert.doesNotMatch(readme, /Статус ТЗ/); }); test('#553: канон разделяет review и исполнение тестов и не возвращает новые ТЗ в архив', () => { const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8'); const process = read('PROCESS.md'); const agents = read('AGENTS.md'); const status = read('docs/STATUS.md'); assert.doesNotMatch(process, /ревью[\s\S]{0,120}заменяет тестирование/i); assert.doesNotMatch(agents, /review[^\n]{0,80}stands in for testing/i); assert.doesNotMatch(process, /получает\s+нормальный файл ТЗ/i); assert.match(process, /полное ТЗ в теле issue по §7\.1/); assert.match(process, /Приёмка проверяет результат для человека/); assert.match(process, /async \(порядок, отмена/); assert.match(process, /zoom\/DPR/); assert.match(process, /host\/input/); assert.match(process, /Ревьюер отвечает за полноту доказательств AC/); assert.match(process, /проверено чтением, не\s+исполнением/); assert.doesNotMatch(status, /py3\.13|Python 3\.13/i); assert.match(status, /uses repository-pinned Python/); assert.doesNotMatch(agents, /Node 22|Python 3\.14/, 'точные runtime pins читаются из исполняемых источников, не из памятки'); }); test('#555: bounded reconciler wakes only lost review requests and emits one machine summary', () => { const read = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8'); const workflow = read('.github/workflows/_process-reconcile.yml'); // #623: run-name и расписание — у тонких вызывающих файлов, тела — в `_*.yml`. const caller = read('.github/workflows/process-reconcile.yml'); const processWorkflow = read('.github/workflows/process.yml'); const process = read('PROCESS.md'); const agents = read('AGENTS.md'); assert.match(processWorkflow, /run-name: "process #\$\{\{ github\.event\.issue\.number \}\} · \$\{\{ github\.event\.label\.name \}\}/, 'run identity includes issue and requested stage'); assert.match(caller, /cron: '7,37 \* \* \* \*'/); assert.match(caller, /workflow_dispatch:/); assert.match(workflow, /ref: dev/); assert.match(workflow, /secrets\.HP_PROCESS_TOKEN/); assert.match(workflow, /node scripts\/process-reconcile\.mjs[\s\S]*--apply="\$APPLY"/); assert.match(workflow, /--max-actions=5/); assert.match(workflow, /- name: Опубликовать компактный machine-readable итог\n\s+if: always\(\)/, 'summary artifact survives a failed write or reconciler exit'); assert.match(workflow, /process-reconcile-\$\{\{ github\.run_id \}\}-\$\{\{ github\.run_attempt \}\}/); assert.match(process, /houseplan-process-reconcile\/v1/); assert.match(process, /второй вызов модели или S8 по догадке запрещены/); assert.match(agents, /bounded queue reconciler \(#555\)/); }); test('#551: gates, модель и интеграция имеют независимые jobs, contracts и бюджеты', () => { const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const job = (name, next) => { const start = workflow.indexOf(`\n ${name}:`); assert.ok(start > 0, `job ${name} найден`); const end = next ? workflow.indexOf(`\n ${next}:`, start + 1) : workflow.length; assert.ok(end > start, `граница job ${name} найдена`); return workflow.slice(start, end); }; const prepare = job('prepare', 'model_review'); const model = job('model_review', 'integrate'); const integrate = job('integrate'); assert.match(prepare, /timeout-minutes: 55/); assert.match(model, /timeout-minutes: 45/); assert.match(integrate, /timeout-minutes: 55/); assert.match(prepare, /node "\$TOOLS\/scripts\/validate-gate\.mjs"/); assert.doesNotMatch(model, /validate-gate\.mjs/, 'ожидания Validate нет в бюджете модели'); assert.match(model, /needs: \[guard, prepare\]/); assert.match(integrate, /needs: \[guard, prepare, model_review\]/); const seal = model.slice( model.indexOf('- name: Запечатать результат модели'), model.indexOf('- name: Передать результат интеграции'), ); assert.match(seal, /printf '%s' "\$OUT" > "\$RUNNER_TEMP\/verdict\.json"/, 'structured verdict сохраняется как объект до проверки'); assert.match(seal, /and \(\.summary \| type == "string"\)' "\$RUNNER_TEMP\/verdict\.json" >\/dev\/null/, 'jq валидирует сохранённый объект, не записывает boolean предиката вместо него'); assert.doesNotMatch(seal, /jq -e '[\s\S]*> "\$RUNNER_TEMP\/verdict\.json"/, 'stdout предиката jq не становится payload интеграции'); const resultOutput = integrate.slice( integrate.indexOf('- name: Проверить полноту и происхождение результата'), integrate.indexOf('- name: Опубликовать документ ревью'), ); assert.match(resultOutput, /cat "\$dir\/verdict\.json" >> "\$GITHUB_OUTPUT"[\s\S]*printf '\\nEOF_RESULT\\n' >> "\$GITHUB_OUTPUT"/, 'закрывающий delimiter начинается с новой строки даже у JSON без финального LF'); assert.doesNotMatch(resultOutput, /cat "\$dir\/verdict\.json"\n\s+echo 'EOF_RESULT'/, 'cat без гарантированного LF не приклеивает delimiter к JSON'); assert.match(model, /ref: \$\{\{ needs\.prepare\.outputs\.material_sha \}\}/, 'модель получает exact material, а не подвижную ветку'); assert.match(prepare, /review-prepared-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/); assert.match(model, /sha256sum .*> "\$RUNNER_TEMP\/review-result\/manifest\.sha256"|sha256sum/, 'модель запечатывает результат контрольными суммами'); assert.match(model, /test "\$\(git rev-parse HEAD\)" = "\$MATERIAL_SHA"/); assert.match(model, /review-result-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/); // #556: разбор того же контракта переехал из inline-shell в // `scripts/review-result-gate.mjs` — ровно ради враждебных фикстур, которых в // YAML поставить было некуда (`test/review-result-gate.test.mjs`). Здесь // проверяется, что привилегированная стадия ходит через него и передаёт ему // весь паспорт, а не его часть. assert.match(integrate, /node "\$TOOLS\/scripts\/review-result-gate\.mjs" --dir="\$dir"/); for (const field of ['MATERIAL_SHA', 'MATERIAL_TREE', 'STAGE', 'CYCLE', 'BRANCH', 'ISSUE']) { assert.match(integrate, new RegExp(`^\\s+${field}: `, 'm'), `${field} передаётся гейту`); } assert.match(integrate, /PREPARE_RESULT: \$\{\{ needs\.prepare\.result \}\}/); assert.match(integrate, /MODEL_RESULT: \$\{\{ needs\.model_review\.result \}\}/); // #696: `ship` в рамках модель не вызывает — её исход не ждётся только там. assert.match(integrate, /if \[ "\$REUSE" != "true" \] && \[ "\$SHIP" != "true" \] && \[ "\$MODEL_RESULT" != "success" \]; then/, 'интеграция не доверяет failed/cancelled/skipped модели'); assert.match(integrate, /цикл ревью не израсходован/); assert.match(integrate, /Бюджеты стадий \(#551\)/, 'длительности публикуются раздельно'); }); // #539: `workflow_dispatch` принимает только ref, а не SHA. Конвейер сам // переписывает ветку ребейзом и тут же просит GitHub разрешить эту же ссылку: // 12.09 на #536 диспатч встал на ДОпушевый SHA, гейт не нашёл прогона на // материале и вернул задачу автору, которому чинить было нечего. Шаг ребейза // обязан дождаться, что ссылка доехала, и спрашивать об этом REST — через него // же идёт диспатч. test('конвейер: ребейз не заканчивается, пока ссылка не укажет на новую вершину (#539)', () => { const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const rebase = workflow.slice( workflow.indexOf(' - name: Привести ветку к dev\n'), workflow.indexOf(' - name: Зафиксировать SHA материала ревью\n'), ); assert.ok(rebase.length > 0, 'шаг ребейза найден'); assert.match(rebase, /gh api "repos\/\$\{\{ github\.repository \}\}\/git\/ref\/heads\/\$BRANCH"/, 'вершина спрашивается у REST, а не у git ls-remote'); assert.match(rebase, /^\s+GH_TOKEN: \$\{\{ secrets\.HP_PROCESS_TOKEN \}\}$/m, 'токен для REST есть'); const wait = rebase.indexOf('git api') >= 0 ? -1 : rebase.indexOf('gh api'); const fetchLocal = rebase.indexOf('git fetch -q origin "+refs/heads/$BRANCH'); assert.ok(wait > 0 && wait < fetchLocal, 'ожидание стоит после push и до конца шага'); // Сама сверка, а не только её обвязка: без этой строки цикл выходит на первой // же итерации, и ожидание становится декорацией. assert.match(rebase, /if \[ "\$seen" = "\$after" \]; then settled=true; break; fi/, 'ответ REST сверяется с новой вершиной'); assert.match(rebase, /ссылка \$BRANCH за минуту не стала указывать/, 'не доехавшая ссылка — отказ, а не молчание'); }); // #556: до этой правки один блок `permissions` на весь workflow выдавал // `issues: write` и OIDC каждой стадии, включая единственную недоверенную — // работу модели. Права выдаются по job и по факту использования. test('конвейер: права выдаются по job, модель не пишет в issue (#556)', () => { const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const head = workflow.slice(0, workflow.indexOf('\njobs:')); assert.match(head, /^permissions:\n contents: read\n/m, 'на уровне workflow остаётся только чтение'); assert.doesNotMatch(head, /^\s+issues: write$/m, 'issues: write на весь workflow больше не выдаётся'); assert.doesNotMatch(head, /^\s+id-token: write$/m, 'OIDC на весь workflow больше не выдаётся'); const jobBlock = (name, next) => workflow.slice(workflow.indexOf(`\n ${name}:\n`), workflow.indexOf(`\n ${next}:\n`)); const model = jobBlock('model_review', 'integrate'); assert.match(model, /^\s+permissions:\n\s+contents: read\n\s+issues: write$/m, 'модели — чтение репозитория и ровно одно право записи: комментарий в issue'); assert.doesNotMatch(model.slice(0, model.indexOf('steps:')), /contents: write/, 'модель не получает права записи в репозиторий'); for (const [name, next] of [['guard', 'prepare'], ['prepare', 'model_review']]) { assert.match(jobBlock(name, next), /^\s+permissions:\n\s+contents: read\n\s+issues: write$/m, `${name} пишет в issue и только туда`); } const integrate = workflow.slice(workflow.indexOf('\n integrate:\n')); assert.match(integrate, /^\s+permissions:\n\s+contents: read\n\s+issues: write$/m); }); // #556 r1 H1: объявленные `permissions:` у model_review ничего не ограничивали, // пока claude-code-action меняла OIDC на собственный App-токен: его дефолт — // `contents/issues/pull_requests: write`, и `ghs_…` от claude[bot] лежал прямо в // окружении Bash-инструмента модели. Потолком права становятся только при // переданном ambient-токене: `OVERRIDE_GITHUB_TOKEN` замыкает обмен в // `setupGitHubToken`. Свидетель стоит на проводке, потому что снятие одной // строки возвращает модели запись в репозиторий молча — прогон остаётся зелёным. test('ревью: модель работает job-scoped токеном, а не App-обменом (#556)', () => { const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const model = workflow.slice(workflow.indexOf('\n model_review:\n'), workflow.indexOf('\n integrate:\n')); const review = model.slice(model.indexOf(' - name: Review\n')); const withBlock = review.slice(review.indexOf(' with:'), review.indexOf(' prompt: |')); assert.match(withBlock, /^\s+github_token: \$\{\{ secrets\.GITHUB_TOKEN \}\}$/m, 'шагу Review передан ambient job-scoped токен'); assert.doesNotMatch(withBlock, /additional_permissions/, 'права не расширяются через additional_permissions'); // Без обмена OIDC не нужен, и заявка на него — признак вернувшегося App-токена. assert.doesNotMatch(model.slice(0, model.indexOf('steps:')), /id-token: write/, 'OIDC этой стадии больше не выдаётся'); }); // #621: каталог docs/reviews перевалил за 1 000 файлов, а `contents` API // перечисляет не больше 1 000 записей и обрезает молча. Занижённый счёт по // файлам — это повтор номера захода и документ поверх предыдущего (#454). // Счёт обязан не зависеть от размера каталога, а листинг в workflow — идти // через Git Trees API, где потолок 100 000 и обрезка объявлена флагом. test('счёт раундов не зависит от числа файлов в каталоге (#621 AC1)', () => { const names = []; for (let i = 1; i <= 1_200; i += 1) names.push(`CODE-REVIEW-${i}-r1.md`, `SPEC-REVIEW-${i}-r1.md`); // Свои документы — в хвосте списка, за пределами первой тысячи: обрезанный // листинг их бы не увидел. names.push('CODE-REVIEW-621-r2.md', 'CODE-REVIEW-621-r3.md'); assert.ok(names.length > 1_000); const { rounds, skipped } = reviewRoundsFromFiles(names, 'CODE-REVIEW', '621'); assert.deepEqual(rounds, [1, 2, 3]); assert.deepEqual(skipped, []); assert.equal(attemptFromRounds(rounds), 4); }); test('guard перечисляет docs/reviews деревом, а не contents, и без предупреждения о потолке (#621 AC2)', () => { const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const guard = workflow.slice(workflow.indexOf('\n guard:\n'), workflow.indexOf('\n prepare:\n')); assert.doesNotMatch(guard, /contents\/docs\/reviews\?ref=/, 'листинг каталога через contents API снят'); assert.doesNotMatch(guard, /1000 файлов/, 'предупреждение о потолке удалено вместе с зависимостью'); assert.match(guard, /gh api "repos\/\$REPO\/commits\/\$1" --jq '\.commit\.tree\.sha'/, 'дерево берётся от коммита ветки задачи'); assert.match(guard, /for entry in docs reviews; do/, 'спуск по уровням docs → reviews'); assert.match(guard, /gh api "repos\/\$REPO\/git\/trees\/\$sha"/, 'листинг — Git Trees API'); assert.match(guard, /if \.truncated then error\("truncated"\)/, 'обрезанное дерево — отказ, не частичный список'); assert.match(guard, /if ! tree_names "\$target" > "\$names" 2>\/dev\/null; then\n\s+: > "\$names"/, 'отказ листинга отключает счёт по файлам, страховка по комментариям остаётся'); // Тела своих документов по-прежнему читаются поштучно: у одиночного файла // потолка нет. assert.match(guard, /contents\/docs\/reviews\/\$name\?ref=\$target/); }); /** * Шаг `decide` job guard как есть — настоящим bash (#707). Подменён только * `gh`: метки, ветка задачи, compare API; счётчик раундов и трек считают * настоящие `review-doc-guard.mjs` и `process-track.mjs` из рабочей копии. */ function runGuard(t, { labels, compare = null, branch = true }) { const root = mkdtempSync(join(tmpdir(), 'hp-guard-')); t.after(() => rmSync(root, { recursive: true, force: true })); const bin = join(root, 'bin'); mkdirSync(bin); writeFileSync(join(root, 'labels'), `${labels.join('\n')}\n`); if (compare) writeFileSync(join(root, 'compare'), `${compare.join('\n')}\n`); writeFileSync(join(bin, 'gh'), [ '#!/usr/bin/env bash', 'printf \'%s\\n\' "$*" >> "$FAKE_DIR/calls"', 'case "$*" in', ' "issue view 7 --repo o/r --json labels --jq .labels[].name") cat "$FAKE_DIR/labels" ;;', ' "issue view 7 --repo o/r --json comments") echo \'{"comments":[]}\' ;;', ` "api repos/o/r/git/matching-refs/heads/issue/7- --jq .[].ref") ${branch ? "echo refs/heads/issue/7-x" : ':'} ;;`, ' "api repos/o/r/commits/issue/7-x --jq .commit.committer.date") echo 2026-10-01T00:00:00Z ;;', ' "api repos/o/r/compare/dev...issue/7-x --jq .files[].filename") [ -f "$FAKE_DIR/compare" ] || exit 1; cat "$FAKE_DIR/compare" ;;', ' "issue comment"*|"issue edit"*) ;;', ' *) exit 1 ;;', 'esac', '', ].join('\n'), { mode: 0o755 }); const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const lines = workflow.slice(workflow.indexOf(' - id: decide\n')).split('\n'); const from = lines.indexOf(' run: |'); const body = []; for (const line of lines.slice(from + 1)) { if (line.trim() && !/^ {10}/.test(line)) break; body.push(line.replace(/^ {10}/, '')); } const context = { repository: 'o/r', server_url: 'https://github.com', run_id: '42' }; const script = body.join('\n').replace(/\$\{\{ github\.(\w+) \}\}/g, (_, key) => context[key]); const output = join(root, 'output'); writeFileSync(output, ''); const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], { cwd: fileURLToPath(new URL('..', import.meta.url)), encoding: 'utf8', env: { ...process.env, PATH: `${bin}:${process.env.PATH}`, FAKE_DIR: root, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: join(root, 'summary'), GH_TOKEN: 'x', REPO: 'o/r', LABEL: 'S7-code-review', NUM: '7', }, }); const out = Object.fromEntries(readFileSync(output, 'utf8').split('\n').filter((l) => /^\w+=/.test(l)).map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)])); return { status: r.status, stdout: r.stdout, stderr: r.stderr, out }; } test('r1 #695: guard даёт инфраструктуре без трековой метки лимит show (§5.1)', (t) => { if (process.platform === 'win32' || spawnSync('bash', ['--version']).status !== 0) { t.skip('bash недоступен'); return; } const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8'); const guard = workflow.slice(workflow.indexOf('\n guard:'), workflow.indexOf('\n prepare:')); // #707: трек и лимит — из process-track.mjs; своей логики трека в guard нет. assert.match(guard, /gh api "repos\/\$REPO\/compare\/dev\.\.\.\$branch" --jq '\.files\[\]\.filename' > "\$changed" 2>\/dev\/null \\\n\s+\|\| : > "\$changed"/, 'признак инфраструктуры — пути диффа ветки против dev; отказ API — пустой список'); assert.match(guard, /node scripts\/process-track\.mjs limit --labels="\$labels" --files="\$changed"/); assert.doesNotMatch(guard, /limit=2|SMALL|TRIVIAL/, 'лимит 2 guard сам не ставит'); const infra = ['scripts/x.mjs', '.github/workflows/y.yml']; const cases = [ [{ labels: ['S7-code-review'], compare: infra }, 2, 'инфраструктура без трековой метки — show'], [{ labels: ['S7-code-review', 'track:ask'], compare: infra }, 4, 'явная метка главнее признака инфраструктуры'], [{ labels: ['S7-code-review'], compare: ['src/a.ts', ...infra] }, 4, 'продукт — ask'], [{ labels: ['S7-code-review'], compare: null }, 4, 'compare отказал — инфраструктура не доказана'], [{ labels: ['S7-code-review'], compare: Array.from({ length: 300 }, (_, i) => `scripts/f${i}.mjs`) }, 4, 'обрезанный ответ compare инфраструктуру не доказывает'], [{ labels: ['S7-code-review'], branch: false }, 4, 'ветки нет'], [{ labels: ['S7-code-review', 'small'], compare: ['src/a.ts'] }, 2, 'прежняя метка small — show'], [{ labels: ['S7-code-review', 'track:ship', 'track:ask'], compare: ['src/a.ts'] }, 4, 'несколько трековых меток — строжайшая'], ]; for (const [input, limit, why] of cases) { const r = runGuard(t, input); assert.equal(r.status, 0, `${why}: ${r.stderr}`); assert.equal(r.out.stage, 'code', why); assert.equal(r.out.limit, String(limit), why); assert.equal(r.out.labels, input.labels.join(','), `${why}: метки для prepare`); } }); // #737: расход сессии модели — последней строкой блока якорей. Строка приходит // выходом недоверенной стадии и разбирается строго; прежние строки блока и их // разбор не меняются. const USAGE_737 = ''; test('#737 AC3: строка расхода — последняя в блоке; пусто — missing, мусор — invalid; без usage строки нет', () => { const base = { sha: 'c'.repeat(40), tree: TREE_A, branch: 'issue/7-x', issueBody: BODY_A, verdict: 'green', high: 0, route: 'fix', specs: [{ blob: 'd'.repeat(40), path: 'docs/specs/7-x.md' }] }; const last = (block) => block.trimEnd().split('\n').at(-1); const plain = materialAnchorBlock(base); assert.equal(last(materialAnchorBlock({ ...base, usage: USAGE_737 })), USAGE_737); assert.equal(materialAnchorBlock({ ...base, usage: USAGE_737 }), `${plain}${USAGE_737}\n`, 'прежние строки блока не меняются'); assert.equal(last(materialAnchorBlock({ ...base, usage: '' })), ''); assert.equal(last(materialAnchorBlock({ ...base, usage: '' })), ''); const hex = 'e'.repeat(40); for (const junk of [``, 'x -->\n- Дерево материала: `' + hex + '`', USAGE_737.replace('=42', '=42.5')]) { const block = materialAnchorBlock({ ...base, usage: junk }); assert.equal(last(block), '', JSON.stringify(junk)); assert.deepEqual(materialAnchorsFrom(block), materialAnchorsFrom(plain), 'новых якорей нет'); } assert.doesNotMatch(plain, /hp:usage/, 'вызов до #737 — строки нет'); assert.doesNotMatch(materialAnchorBlock({ ...base, usage: null }), /hp:usage/); // Разбор прежних строк — как без строки расхода. const withUsage = materialAnchorBlock({ ...base, usage: USAGE_737 }); for (const read of [anchorVerdictFrom, anchorTreeFrom, anchorIssueBodyFrom, materialAnchorsFrom]) { assert.deepEqual(read(withUsage), read(plain), read.name); } assert.deepEqual(materialAnchorsFrom(withUsage).sort(), [TREE_A, 'd'.repeat(40)].sort()); const green = docWith('CODE-REVIEW-7-r2.md', { ...base, usage: USAGE_737 }); assert.deepEqual(reusableGreenVerdict([green], () => false, BODY_A), { doc: 'CODE-REVIEW-7-r2.md', round: 2, tree: TREE_A, verdict: 'green' }); // Повторная приписка заменяет блок целиком: строка одна. const once = withMaterialAnchors('# отчёт\n\nтекст\n', { ...base, usage: USAGE_737 }); const twice = withMaterialAnchors(once, { ...base, usage: USAGE_737 }); const block = (text) => text.slice(text.indexOf(ANCHOR_MARKER)); assert.equal(block(twice), block(once)); assert.equal(twice.split(USAGE_737).length - 1, 1, 'строка расхода одна'); }); test('#737 AC3: CLI --anchor пишет строку из --usage; без флага (вызов до #737) строки нет', (t) => { const dir = mkdtempSync(join(tmpdir(), 'hp-737-anchor-')); t.after(() => rmSync(dir, { recursive: true, force: true })); const doc = join(dir, 'CODE-REVIEW-7-r1.md'); const anchor = (...flags) => { writeFileSync(doc, '# CODE-REVIEW-7-r1\n'); const r = spawnSync(process.execPath, [fileURLToPath(new URL('../scripts/review-doc-guard.mjs', import.meta.url)), `--anchor=${doc}`, `--sha=${'c'.repeat(40)}`, `--tree=${TREE_A}`, '--branch=issue/7-x', '--verdict=green', '--high=0', ...flags], { encoding: 'utf8' }); assert.equal(r.status, 0, r.stderr); return readFileSync(doc, 'utf8').trimEnd().split('\n').at(-1); }; assert.equal(anchor(`--usage=${USAGE_737}`), USAGE_737); assert.equal(anchor('--usage='), ''); assert.equal(anchor('--usage=garbage'), ''); assert.equal(anchor(), '- Вердикт конвейера: `green` · High 0'); });