mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +00:00
The code-review prompt sat at exactly 1 400 of its 1 400 words (#634), so any new line turned the budget test red, and #707/#726 already had to route their notes through job outputs. Part of the text was dead or a retelling of the reviewer digest, which #634 says the prompt must not repeat: - the mutants fragment of the track line: since #709 `mutants` is always false, so «прогнаны Validate» was unreachable; - «Отсутствие мутантов по диффу — не находка» in the show line: a rule of every track, already in REVIEWER.md «Трек show» and §10.4; - three retellings — the repeated round, the gate scope and the severity paragraph — now one-line references to the REVIEWER.md sections. The ban on a separate issue for an in-scope Medium stays in the prompt: the model files issues itself, and that mistake is expensive. What only the prompt said moves into REVIEWER.md with links to the canon: the spec delta is the diff of the issue body, a doubt about locality means a full review with a stated reason, the three smoke-select answers (docs/TESTING.md), and geometry without invariants in the report is an unrun gate. The prompt is now 1 130 words; the 1 400 threshold stays, the difference is headroom. A new test ties every «docs/process/REVIEWER.md, «X»» reference in the prompt to an existing `## X` section. Issue: #750 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
236 lines
21 KiB
JavaScript
236 lines
21 KiB
JavaScript
// #634: ролевые конспекты PROCESS.md не расходятся с каноном.
|
||
//
|
||
// `docs/process/AUTHOR.md` и `docs/process/REVIEWER.md` — вход автора и
|
||
// ревьюера вместо полного PROCESS.md. Конспект, который тихо разошёлся с
|
||
// каноном, хуже его отсутствия: агент действует по устаревшей выжимке. Поэтому
|
||
// (AC2) каждый пункт конспекта ссылается на существующий раздел канона, а
|
||
// ключевые правила (перечень ниже) записаны в конспекте теми же словами, что в
|
||
// каноне, и именно в том разделе, на который пункт ссылается. Меняется
|
||
// формулировка в PROCESS.md — краснеет этот тест, и конспект правится тем же
|
||
// коммитом.
|
||
import assert from 'node:assert/strict';
|
||
import { existsSync, readFileSync } from 'node:fs';
|
||
import { dirname, join, normalize } from 'node:path';
|
||
import test from 'node:test';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { headings, markdownLinks, sectionText } from '../scripts/md-anchors.mjs';
|
||
|
||
const ROOT = fileURLToPath(new URL('..', import.meta.url));
|
||
const read = (rel) => readFileSync(join(ROOT, rel), 'utf8').replace(/\r\n?/g, '\n');
|
||
const DIGESTS = ['docs/process/AUTHOR.md', 'docs/process/REVIEWER.md'];
|
||
const CANON = 'PROCESS.md';
|
||
// Сравнение формулировок: без Markdown-выделения, пробелы схлопнуты, регистр
|
||
// не важен (пункт конспекта начинается с заглавной там, где в каноне середина фразы).
|
||
const norm = (text) => text.replace(/\*\*/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||
|
||
/** Пункты верхнего уровня: строка `- ` и её продолжения с отступом. */
|
||
function topLevelBullets(text) {
|
||
const bullets = [];
|
||
let current = null;
|
||
for (const line of text.split('\n')) {
|
||
if (line.startsWith('- ')) { current = [line]; bullets.push(current); continue; }
|
||
if (current && /^\s+\S/.test(line)) { current.push(line); continue; }
|
||
current = null;
|
||
}
|
||
return bullets.map((lines) => lines.join('\n'));
|
||
}
|
||
|
||
// Ключевые правила: формулировка, дословная в каноне и в конспекте, и раздел
|
||
// канона, где она записана. Отбор — то, нарушение чего дороже всего стоило
|
||
// процессу: вход в код, трек, вопросы владельцу, трейлеры, доказательство
|
||
// защитных AC, гейты, дисциплина хендоффа и формат вердикта.
|
||
const KEY_RULES = {
|
||
'docs/process/AUTHOR.md': [
|
||
['1-основное-правило', 'Изменение продуктового кода без issue запрещено'],
|
||
['1-основное-правило', 'ни одного файла класса A'],
|
||
['1-основное-правило', 'D сильнее A'],
|
||
['3-правила', 'Ровно одна метка статуса'],
|
||
['3-правила', 'Статус меняется до действия, а не после'],
|
||
['5-треки-ship-show-ask--метка-владельца', 'Метка владельца главнее критериев'],
|
||
['5-треки-ship-show-ask--метка-владельца', 'ожидаемое поведение уже зафиксировано'],
|
||
// #707: трек подтверждает строка владельца, метка без неё — предложение.
|
||
['5-треки-ship-show-ask--метка-владельца', 'строкой `Трек: <ship|show|ask> — решение владельца` в начале строки'],
|
||
['5-треки-ship-show-ask--метка-владельца', 'Метка без такой строки — предложение'],
|
||
['5-треки-ship-show-ask--метка-владельца', 'Агент эту строку не пишет никогда'],
|
||
['71-цепочка', 'Владельцу задаются только продуктовые вопросы'],
|
||
['71-цепочка', 'issue остаётся в `S3-spec` и получает `blocked`'],
|
||
['26-в-разработке--реализация', 'каждый коммит с файлами классов A, B или D несёт трейлеры `Issue: #<NN>` и `User-Visible: yes|no`'],
|
||
['3-правила', '`User-Visible: yes` требует правок в обоих changelog в том же коммите'],
|
||
['26-в-разработке--реализация', 'шесть классов риска'],
|
||
['26-в-разработке--реализация', 'Скоуп не расширяется'],
|
||
['26-в-разработке--реализация', 'Документация — в том же коммите, что и поведение'],
|
||
['27-код-ревью', 'Защитный AC доказывается таблицей «чем краснеет»'],
|
||
['27-код-ревью', 'Пустой третий столбец — находка Medium'],
|
||
['27-код-ревью', 'Контракты по монолиту — исполнением, не regex по тексту'],
|
||
['8-гейты', 'Новый код не добавляет `any`'],
|
||
['8-гейты', '`// any-ok: <конкретная причина>`'],
|
||
['8-гейты', 'Одно число — один источник'],
|
||
['8-гейты', 'Полные наборы — предрелизный гейт, а не гейт ревью'],
|
||
['8-гейты', 'Упавший предрелизный гейт автор чинит и повторно прогоняет'],
|
||
['3-правила', 'проверено чтением, не исполнением'],
|
||
// #726: исчерпание видно сразу, а не на следующем S7.
|
||
['4-лимит-циклов-ревью-4', 'вердикт, исчерпавший бюджет, сразу ставит `review-4`'],
|
||
['104-событийный-конвейер-метка-как-триггер', 'Один хендофф — один пуш'],
|
||
['104-событийный-конвейер-метка-как-триггер', 'Ветка приводится к `dev` до ревью, а не после'],
|
||
['104-событийный-конвейер-метка-как-триггер', 'Автор обязан дождаться вердикта, а не заканчивать сессию'],
|
||
['104-событийный-конвейер-метка-как-триггер', 'После прогона ревью метка меняется всегда'],
|
||
['72-шаблоны-комментариев', 'Вперёд двигает только зелёный вердикт'],
|
||
['12-запрещено', 'force-push в `dev`'],
|
||
['12-запрещено', 'попутные правки «раз уж я здесь»'],
|
||
// #729: черновик track:ask во время ревью ТЗ — исключение из правила №1.
|
||
['118-черновик-кода-во-время-ревью-тз', 'черновик — возможность, а не обязанность'],
|
||
['118-черновик-кода-во-время-ревью-тз', 'в ветку черновик попадает только после зелёного ревью ТЗ'],
|
||
],
|
||
'docs/process/REVIEWER.md': [
|
||
['27-код-ревью', 'Ревьюер ≠ исполнитель'],
|
||
['24-тз-на-ревью', '`docs/reviews/SPEC-REVIEW-<NN>-r<N>.md`'],
|
||
['27-код-ревью', '`docs/reviews/CODE-REVIEW-<tag|NN>-r<N>.md`'],
|
||
['27-код-ревью', 'Ревьюер отвечает за полноту доказательств AC, а не заменяет их исполнение'],
|
||
['27-код-ревью', 'проверено чтением, не исполнением'],
|
||
['27-код-ревью', 'Защитный AC доказывается таблицей «чем краснеет»'],
|
||
['27-код-ревью', 'Пустой третий столбец — находка Medium, а не примечание'],
|
||
['27-код-ревью', '«Тест умеет падать» без названной мутации доказательством не является'],
|
||
['27-код-ревью', 'Вердикт привязан к SHA (#312)'],
|
||
['27-код-ревью', 'новое имя в нём — находка ревью, а не запись в список'],
|
||
['8-гейты', 'ревьюер обязан перечислить, какие гейты прогнал, какие нет и почему'],
|
||
['8-гейты', 'какое число в этом диффе видно дважды и один ли у него источник'],
|
||
['8-гейты', 'Полные наборы — предрелизный гейт, а не гейт ревью'],
|
||
['210-повторный-раунд-ревью--объём-по-дельте', 'Предмет повторного раунда — дельта, а не задача целиком'],
|
||
['210-повторный-раунд-ревью--объём-по-дельте', 'Если SHA не резолвится — это не находка, а обычное дело'],
|
||
['210-повторный-раунд-ревью--объём-по-дельте', 'SHA, мёртвый уже в момент публикации отчёта'],
|
||
['210-повторный-раунд-ревью--объём-по-дельте', 'раздел «Унаследовано из r<N−1>»'],
|
||
['210-повторный-раунд-ревью--объём-по-дельте', 'Разбор остаётся полным, если дельта не локальна'],
|
||
['210-повторный-раунд-ревью--объём-по-дельте', 'Сокращается объём разбора, а не строгость'],
|
||
['3-правила', 'High блокирует. Medium в скоупе чинится в текущем issue'],
|
||
['72-шаблоны-комментариев', '`Вердикт: зелёный/жёлтый/красный · заход r<N> · блокирующих циклов K/<лимит> · High: N · Medium: N → в задаче | #… · Документ: docs/reviews/…`'],
|
||
['72-шаблоны-комментариев', 'Вперёд двигает только зелёный вердикт'],
|
||
['4-лимит-циклов-ревью-4', 'Зелёный вердикт цикла не образует'],
|
||
['4-лимит-циклов-ревью-4', 'вердикт, исчерпавший бюджет, сразу ставит `review-4`'],
|
||
['71-цепочка', 'Технический спор автора и ревьюера решается вердиктом, а не владельцем'],
|
||
['12-запрещено', 'Medium-находки, оставленные как TODO в документе ревью'],
|
||
// #727: ночное пакетное ревью ship переиспользуется гейтом беты по патч-набору.
|
||
['117-пакетное-ревью-ship-перед-бетой', 'гейт беты принимает ночной документ при том же патч-наборе'],
|
||
// #729: готовый черновик не довод за непринятое ТЗ.
|
||
['24-тз-на-ревью', 'ревьюер ТЗ не читает и доводом не считает'],
|
||
],
|
||
};
|
||
|
||
test('#634 конспект: каждая ссылка ведёт на существующий файл и заголовок', () => {
|
||
for (const digest of DIGESTS) {
|
||
const text = read(digest);
|
||
const links = markdownLinks(text);
|
||
assert.ok(links.some((link) => link.anchor && link.file.endsWith(CANON)), `${digest}: нет ни одной ссылки на раздел ${CANON}`);
|
||
for (const link of links) {
|
||
const target = link.file ? normalize(join(dirname(digest), link.file)).replace(/\\/g, '/') : digest;
|
||
assert.ok(existsSync(join(ROOT, target)), `${digest}: ссылка на несуществующий файл ${link.target}`);
|
||
if (!link.anchor) continue;
|
||
const anchors = new Set(headings(read(target)).map((heading) => heading.anchor));
|
||
assert.ok(anchors.has(link.anchor), `${digest}: нет заголовка ${target}#${link.anchor}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('#634 конспект: каждый пункт ссылается на раздел канона (AC2)', () => {
|
||
for (const digest of DIGESTS) {
|
||
const bullets = topLevelBullets(read(digest));
|
||
assert.ok(bullets.length >= 20, `${digest}: подозрительно мало пунктов (${bullets.length})`);
|
||
for (const bullet of bullets) {
|
||
const cited = markdownLinks(bullet).some((link) => link.anchor && link.file.endsWith(CANON));
|
||
assert.ok(cited, `${digest}: пункт без ссылки на раздел ${CANON}:\n${bullet}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('#634 конспект: ключевые правила дословно в каноне и в пункте со ссылкой на их раздел', () => {
|
||
const canon = read(CANON);
|
||
for (const [digest, rules] of Object.entries(KEY_RULES)) {
|
||
const bullets = topLevelBullets(read(digest));
|
||
for (const [anchor, phrase] of rules) {
|
||
const section = sectionText(canon, anchor);
|
||
assert.ok(section, `${CANON}: нет раздела #${anchor}`);
|
||
assert.ok(norm(section).includes(norm(phrase)), `${CANON}#${anchor} больше не содержит «${phrase}» — поправьте ${digest}`);
|
||
const owner = bullets.find((bullet) => norm(bullet).includes(norm(phrase)));
|
||
assert.ok(owner, `${digest}: ключевое правило «${phrase}» (${CANON}#${anchor}) пропало из конспекта`);
|
||
assert.ok(markdownLinks(owner).some((link) => link.anchor === anchor),
|
||
`${digest}: пункт с «${phrase}» не ссылается на ${CANON}#${anchor}`);
|
||
}
|
||
}
|
||
});
|
||
|
||
test('#634 конспект: объявляет себя выжимкой, канон — PROCESS.md', () => {
|
||
for (const digest of DIGESTS) {
|
||
const text = norm(read(digest));
|
||
assert.ok(text.includes(norm('Это выжимка, а не канон.')), digest);
|
||
assert.ok(text.includes(norm('при расхождении побеждает он')), digest);
|
||
}
|
||
const canon = norm(read(CANON));
|
||
for (const digest of DIGESTS) assert.ok(canon.includes(norm(digest)), `${CANON} называет ${digest}`);
|
||
});
|
||
|
||
test('#634 промпт ревьюера: конспект вместо пересказа, машинные требования на месте', () => {
|
||
const workflow = read('.github/workflows/_process.yml');
|
||
const start = workflow.indexOf(' prompt: |\n');
|
||
const end = workflow.indexOf(' claude_args: |', start);
|
||
assert.ok(start > 0 && end > start, 'блок prompt найден');
|
||
const prompt = workflow.slice(start, end);
|
||
const flat = prompt.replace(/\s+/g, ' ');
|
||
assert.match(flat, /docs\/process\/REVIEWER\.md/, 'ревьюер читает конспект');
|
||
for (const required of [
|
||
'`Вердикт: зелёный/жёлтый/красный · заход r${{ needs.guard.outputs.cycle }} · блокирующих циклов ${{ needs.guard.outputs.spent }}/${{ needs.guard.outputs.limit }} · High: N · Medium: N → в задаче | #…`',
|
||
'Материал ревью — ровно `${{ needs.prepare.outputs.material_sha }}`',
|
||
'Не делай `git fetch`, `git pull` и `git checkout` на другой коммит',
|
||
'переменной окружения REVIEW_DOC (абсолютный, ВНЕ репозитория)',
|
||
'В самом репозитории не создавай файлов вообще',
|
||
'SPEC-REVIEW для этапа spec, CODE-REVIEW для code',
|
||
'Ты НЕ правишь ни ТЗ, ни продуктовый код',
|
||
'«AC · чем доказан · чем краснеет»',
|
||
'пустой третий столбец — находка Medium',
|
||
'«Закрытие раунда r<N-1>»',
|
||
'«Унаследовано из r<N-1>»',
|
||
'какие гейты прогнал, какие нет и почему',
|
||
'тип, приоритет, S1-new',
|
||
'${{ needs.prepare.outputs.validated_note }}',
|
||
'${{ needs.prepare.outputs.rebase_note }}',
|
||
'Затем верни JSON по схеме',
|
||
]) assert.ok(flat.includes(required), `промпт потерял: ${required}`);
|
||
// Правила живут в каноне; промпт, снова набравший пересказ, — возврат к 60–90 k
|
||
// контекста до первого git diff (аудит 22.09). До #634 — 1 642 слова, к #750 —
|
||
// ровно 1 400, после #750 — 1 130: порог прежний, разница — запас.
|
||
const words = prompt.split(/\s+/).filter(Boolean).length;
|
||
assert.ok(words <= 1400, `промпт ревьюера ${words} слов > 1400`);
|
||
// #750 AC1: мёртвая строка мутантов (с #709 `mutants` всегда false) и правило
|
||
// всех треков, которое REVIEWER.md уже несёт, в промпт не возвращаются.
|
||
assert.doesNotMatch(prompt, /needs\.prepare\.outputs\.mutants/, 'промпт читает мёртвый выход mutants');
|
||
assert.doesNotMatch(flat, /Отсутствие мутантов/, 'правило «отсутствие мутантов — не находка» — в REVIEWER.md, не в промпте');
|
||
});
|
||
|
||
// #750 AC2: промпт вместо пересказа ссылается на разделы конспекта — ссылка
|
||
// без раздела оставила бы ревьюера без правила, которое промпт больше не несёт.
|
||
test('#750 AC2: ссылки промпта на REVIEWER.md ведут в существующие разделы; перенесённое на месте', () => {
|
||
const workflow = read('.github/workflows/_process.yml');
|
||
const prompt = workflow.slice(workflow.indexOf(' prompt: |\n'), workflow.indexOf(' claude_args: |'));
|
||
const flat = prompt.replace(/\s+/g, ' ');
|
||
const reviewer = read('docs/process/REVIEWER.md');
|
||
const sections = new Set(headings(reviewer).filter((h) => h.level === 2).map((h) => h.text));
|
||
const cited = [...flat.matchAll(/docs\/process\/REVIEWER\.md, «([^»]+)»/g)].map((m) => m[1]);
|
||
for (const name of ['Трек show', 'Повторный раунд', 'Объём гейтов', 'Находки и вердикт']) {
|
||
assert.ok(cited.includes(name), `промпт не ссылается на «${name}»`);
|
||
}
|
||
for (const name of cited) assert.ok(sections.has(name), `REVIEWER.md: нет раздела «## ${name}», на который ссылается промпт`);
|
||
// Запрет на отдельный issue для Medium в скоупе остаётся в промпте: модель
|
||
// заводит issue сама, и ошибка дорогая.
|
||
assert.ok(flat.includes('отдельный issue НЕ заводится (#202)'), 'промпт потерял запрет на issue для Medium в скоупе');
|
||
|
||
const bullets = (anchor) => topLevelBullets(sectionText(reviewer, anchor) ?? '');
|
||
const gates = bullets('объём-гейтов');
|
||
const uncertain = gates.find((bullet) => bullet.includes('НЕОПРЕДЕЛЁННОСТЬ'));
|
||
assert.ok(uncertain, '«Объём гейтов»: нет трёх ответов smoke-select с «НЕОПРЕДЕЛЁННОСТЬ»');
|
||
assert.match(uncertain, /связь не доказана/);
|
||
assert.ok(markdownLinks(uncertain).some((link) => link.file.endsWith('TESTING.md')), 'ответы smoke-select ссылаются на docs/TESTING.md');
|
||
assert.ok(gates.some((bullet) => /инвариант/i.test(bullet) && bullet.includes('непрогнанный гейт')),
|
||
'«Объём гейтов»: нет строки «геометрия без инвариантов в отчёте — непрогнанный гейт»');
|
||
const delta = norm(bullets('повторный-раунд').join('\n'));
|
||
assert.ok(delta.includes(norm('дифф тела issue')), '«Повторный раунд»: дельта ТЗ — дифф тела issue');
|
||
assert.ok(delta.includes(norm('Сомнение в локальности — полный разбор')), '«Повторный раунд»: сомнение — полный разбор');
|
||
});
|