Files
Claude 52a56430ab process: an unproven smoke link runs the visual minimum; raster defects need a witness (#690)
The two remaining owner decisions of #690 and the legacy trivial text.

- scripts/smoke-select.mjs: VISUAL_MINIMUM, eight smokes of modes,
  layers and rendering (under a minute locally). An executable diff
  with no proven link now returns and prints it instead of only "the
  reviewer decides"; #687 missed smoke_modes that way (item 1').
- scripts/gate-small.mjs: `--smokes` runs the minimum with the
  selection.
- PROCESS §7.1 and AUTHOR.md: a raster, sharpness or compositing defect
  needs a witness red on the old code for the owner's symptom and the
  owner's confirmation in a real GPU browser (item 4).
- PROCESS §8, TESTING.md: the minimum in the smoke-select rule.
- scripts/task-packet.mjs: legacy `trivial` is product flow read as
  track:show (§5.1), not a short track without a spec.
- Tests; mutants visual-minimum-silent-again,
  visual-minimum-on-proven-link, gate-small-skips-visual-minimum;
  task-packet-trivial-is-product-flow retargeted.

Issue: #690
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-29 08:45:44 +03:00

309 lines
23 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
// Пакет задачи — производное представление, а не новый источник статуса (#496).
//
// Агент, берущий задачу, сегодня собирает одно и то же руками из четырёх мест:
// метки и комментарии issue, ветку и её положение относительно dev, документы
// ревью с их якорями, состояние Validate на вершине. Каждый раз — свои ходы,
// свои ошибки («метка встала раньше push'а», «ревьюер читал не тот SHA»).
// Скрипт делает эту сборку детерминированно и печатает один markdown-пакет:
//
// issue · статус и трек · что можно делать в этом статусе · решения владельца ·
// материал (ветка, SHA, база, Validate) · предыдущий вердикт · AC → свидетель ·
// непроверенное / следующий шаг
//
// Источник правды остаётся GitHub и git: пакет ничего не пишет и ничего не
// решает. Все чтения инъектируемы — `buildPacket(inputs)` чист и покрыт тестами.
//
// node scripts/task-packet.mjs --issue 437 [--repo Matysh/houseplan-card] [--json]
import { spawnSync } from 'node:child_process';
import { isMainModule } from './spawn-portable.mjs';
import { anchorTreeFrom, anchorVerdictFrom, verdictDeclaration } from './review-doc-guard.mjs';
import { classify } from './process-gate.mjs';
import { hasTrackLabel, trackFromLabels } from './process-track.mjs';
export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged'];
/** Что разрешено в статусе — по PROCESS.md, без домыслов. */
export function rightsFor(status, labels = [], { infrastructure = false, infrastructureHint = false } = {}) {
const blocked = labels.includes('blocked');
const exhausted = labels.includes('review-4');
const code = !infrastructure && ['S5-ready', 'S6-in-progress', 'S7-code-review'].includes(status);
const lines = [];
if (exhausted) lines.push('review-4: лимит циклов исчерпан — решение владельца (разделить, отклонить, арбитраж); дальше не двигать');
if (blocked) lines.push('blocked: работа стоит, ждём внешнего решения — коммиты по задаче гейт не пропустит');
lines.push(code
? 'продуктовый код трогать МОЖНО (правило №1)'
: infrastructure
? 'файлы класса A трогать НЕЛЬЗЯ; инфраструктурную реализацию МОЖНО вести сразу по issue (#562)'
: 'продуктовый код трогать НЕЛЬЗЯ: статус не S5/S6/S7 (правило №1)');
if (infrastructureHint) {
lines.push('метка infra — только подсказка, не доказательство и не право: до ветки проверь предполагаемые пути; без class A начинай сразу, при любом class A нужен продуктовый S-flow (#562)');
}
switch (status) {
case 'S1-new': lines.push('следующий шаг: аналитика (S2) — оценки и трек метками (по умолчанию track:show); на track:ship — строка «что меняется и чем проверить» под «## ТЗ», затем S5'); break;
case 'S2-analysis': lines.push('следующий шаг: track:show — до трёх AC под «## ТЗ», затем S5; track:ask — ТЗ (S3) с названным критерием §5'); break;
case 'S3-spec': lines.push('следующий шаг: ТЗ готово → push ветки → метка S4-spec-review (метку после push)'); break;
case 'S4-spec-review': lines.push('идёт ревью ТЗ: ждать вердикт (scripts/wait-verdict.mjs), не править материал'); break;
case 'S5-ready': lines.push('следующий шаг: ветка issue/NN-slug от dev, код по ТЗ, метка S6-in-progress'); break;
case 'S6-in-progress': lines.push('следующий шаг: gate:small + смоки по AC → push ветки → метка S7-code-review (метку после push)'); break;
case 'S7-code-review': lines.push('идёт код-ревью: ждать вердикт, ничего не пушить в ветку — вердикт привязан к SHA (#312)'); break;
case 'S8-merged': lines.push('код в dev, ждёт беты; ничего не делать; issue закроет владелец при выпуске'); break;
default:
lines.push(infrastructure
? 'инфраструктурный вход: реализовать и проверить → push ветки → S7-code-review; ТЗ и S1–S6 не нужны (#562)'
: infrastructureHint
? 'предварительный инфраструктурный вход: после проверки отсутствия class A реализовать → push ветки → S7-code-review; diff станет окончательным доказательством'
: 'статусной метки нет — продуктовая задача вне процесса; вход — первая S*-метка владельца');
}
return lines;
}
/** AC из текста ТЗ или тела issue: строки таблицы `| ACn |` и маркеры `ACn` в начале. */
export function extractAcceptanceCriteria(text) {
const out = new Map();
for (const raw of String(text ?? '').split('\n')) {
const line = raw.trim();
let m = line.match(/^\|\s*\**(AC\d+)\**\s*\|\s*([^|]*)\|/);
if (!m) m = line.match(/^(?:[-*]\s*)?\**(AC\d+)\**[.:)\s-]+(.*)$/);
if (!m) continue;
if (!out.has(m[1])) out.set(m[1], m[2].trim().slice(0, 160));
}
return [...out.entries()].map(([id, text]) => ({ id, text }));
}
/** Где в документе ревью упомянут AC: «проверен» или «без записи». */
export function evidenceFor(acs, reviewDocText) {
const text = String(reviewDocText ?? '');
return acs.map((ac) => {
const re = new RegExp(`(^|[^A-Z0-9])(${ac.id})(?![0-9])`, 'm');
const m = re.exec(text);
if (!m) return { ...ac, evidence: 'без записи в последнем документе ревью' };
const start = m.index + m[1].length;
const line = text.slice(start).split('\n')[0].replace(/^\W+/, '').slice(0, 140);
return { ...ac, evidence: line };
});
}
/** Решения владельца: его комментарии с заголовками/словами решения, свежие первыми. */
export function ownerDecisions(comments, owner) {
return (comments || [])
.filter((c) => c.author === owner && /решени|ответ|принима|утвержда|делаем|не делаем|owner|decision/i.test(String(c.body)))
.slice(-5)
.map((c) => ({ at: c.createdAt, head: String(c.body).trim().split('\n')[0].replace(/^#+\s*/, '').slice(0, 140), url: c.url }));
}
/** Последний вердикт этапа по комментариям (страховка) и по документу (запись конвейера). */
export function lastVerdict(comments, docs, stage) {
const marker = stage === 'spec' ? 'SPEC-REVIEW' : 'CODE-REVIEW';
const fromComments = (comments || []).map((c) => ({ ...c, line: verdictDeclaration(c.body) }))
.filter((c) => c.line && new RegExp(`${marker}|заход r\\d+`).test(c.body));
const latestComment = fromComments.at(-1) || null;
const numbered = (docs || [])
.map((d) => ({ ...d, round: Number((d.name.match(/-r(\d+)\.md$/) || [])[1]) }))
.filter((d) => d.name.startsWith(`${marker}-`) && Number.isFinite(d.round))
.sort((a, b) => b.round - a.round);
const latestDoc = numbered[0] || null;
return {
comment: latestComment ? { at: latestComment.createdAt, line: latestComment.line.trim(), url: latestComment.url } : null,
doc: latestDoc ? {
name: latestDoc.name, round: latestDoc.round,
tree: anchorTreeFrom(latestDoc.text), recorded: anchorVerdictFrom(latestDoc.text),
} : null,
};
}
/**
* Дифф ветки как доказательство ускоренного инфраструктурного трека (#562).
* Документы ревью (`docs/reviews/**`, класс C) пишет конвейер и сама ветка
* ревью ТЗ — они не материал задачи и классификацию не двигают (#632): ветка
* продуктовой S6-задачи до первого кодового коммита содержит только
* SPEC-REVIEW и не должна выглядеть инфраструктурной.
*/
export function branchIsInfrastructure(changedFiles = []) {
const material = changedFiles.filter((name) => !name.startsWith('docs/reviews/'));
return material.length > 0 && !material.some((name) => classify(name) === 'A');
}
const PRE_CODE_STATUSES = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready'];
// Трек по меткам — одна функция на конвейер и пакет (#696): process-track.mjs.
export { hasTrackLabel, trackFromLabels };
/**
* Признаки продуктового S-flow (#632). Инфраструктурная задача входит в поток
* сразу на S7 и никогда не несёт S1–S5, ТЗ и ревью ТЗ; поэтому любой из этих
* признаков делает эвристику «дифф без класса A» неприменимой. S6/S7/S8 сами по
* себе признаком не являются: их носит и инфраструктурная задача после ревью.
* Прежняя метка `trivial` — признак сама по себе (r1 #632): такие задачи шли
* S2 → S5 без ТЗ и без ревью ТЗ, и в S6/S7 никакого другого следа потока у них
* нет. С #695 `trivial` читается как `track:show` (PROCESS §5.1), новым задачам
* не ставится, но на старых остаётся. Инфраструктурный вход её не несёт; `infra`
* рядом с ней — тематическая метка.
*/
export function productFlowEvidence({ status = null, labels = [], issue = {}, specs = [], reviewDocs = [], comments = [] } = {}) {
const reasons = [];
if (PRE_CODE_STATUSES.includes(status)) reasons.push(`статус ${status}`);
if (labels.includes('trivial')) reasons.push('прежняя метка trivial — продуктовый поток, читается как track:show (§5.1)');
if (/^#{1,3}\s*ТЗ(?![\p{L}\p{N}_])/mu.test(String(issue?.body ?? ''))) reasons.push('раздел «## ТЗ» в теле issue');
if (specs.length) reasons.push('файл ТЗ в docs/specs');
if (reviewDocs.some((d) => String(d.name).startsWith('SPEC-REVIEW-'))) reasons.push('документ ревью ТЗ');
else if (comments.some((c) => verdictDeclaration(c.body) && /SPEC-REVIEW-\d+/.test(String(c.body)))) reasons.push('вердикт ревью ТЗ в комментариях');
return reasons;
}
export function buildPacket(inputs) {
const {
issue, labels = [], comments = [], owner = 'Matysh', branch = null, specs = [], reviewDocs = [], validate = null,
} = inputs;
const status = STATUS_LABELS.find((l) => labels.includes(l)) || null;
// Трек сначала определяется статусом и историей issue (#632): прошедшая
// S3/S4/S5 или несущая ТЗ задача — продуктовая, и её право на класс A в
// S5–S7 не отнимается пустым пока диффом.
const productFlow = productFlowEvidence({ status, labels, issue, specs, reviewDocs, comments });
// `infra` — тематическая метка и не даёт процессных прав. Ускоренный трек
// доказывается тем же механическим признаком, что process-gate: в реальном
// diff опубликованной ветки нет ни одного файла класса A — и только вне
// продуктового потока.
const infrastructure = branch?.infrastructure === true && productFlow.length === 0;
const infrastructureHint = branch == null && status == null && labels.includes('infra') && productFlow.length === 0;
// §5.1 (r1 #695): инфраструктурная задача без трековой метки идёт как
// `show`; явная метка владельца главнее. Маршрут при этом остаётся
// инфраструктурным — вход сразу на S7, без S1–S5.
const infraTrack = hasTrackLabel(labels) ? trackFromLabels(labels) : 'show';
const track = infrastructure
? `инфраструктурный · ${infraTrack}`
: infrastructureHint ? `инфраструктурный · ${infraTrack} (предварительно; подтвердить путями/diff)`
: trackFromLabels(labels);
const stage = status === 'S4-spec-review' || status === 'S3-spec' || status === 'S5-ready' ? 'spec' : 'code';
const verdict = lastVerdict(comments, reviewDocs, stage);
// ТЗ живёт в теле issue (#517); архивный файл — источник только у задач до
// перехода, у которых в теле AC нет. Порядок именно такой: тело правится и
// после создания файла, и тогда файл описывает не тот текст, что читает
// ревьюер.
const fromBody = extractAcceptanceCriteria(issue.body);
const acSource = fromBody.length || !specs.length
? issue.body : specs.map((s) => s.text).join('\n');
const acs = evidenceFor(extractAcceptanceCriteria(acSource), reviewDocs.length ? reviewDocs.at(-1).text : '');
const unverified = acs.filter((a) => a.evidence.startsWith('без записи'));
const packet = {
issue: { number: issue.number, title: issue.title, state: issue.state, url: issue.url },
status, track, labels, productFlow, rights: rightsFor(status, labels, { infrastructure, infrastructureHint }),
decisions: ownerDecisions(comments, owner),
material: branch ? {
branch: branch.name, tip: branch.tip, base: branch.base, ahead: branch.ahead, behind: branch.behind,
treeMatchesVerdict: verdict.doc?.tree ? branch.treeWithoutReviews === verdict.doc.tree : null,
validate: validate || { status: 'неизвестно' },
} : null,
verdict,
acceptance: acs,
unverified: unverified.map((a) => a.id),
};
return packet;
}
export function renderPacket(p) {
const L = [];
L.push(`# Пакет задачи #${p.issue.number} — ${p.issue.title}`);
L.push('');
L.push(`Статус: **${p.status || 'без S-метки'}** · трек: ${p.track} · метки: ${p.labels.join(', ') || '—'} · issue ${p.issue.state}`);
if (p.productFlow?.length) L.push(`Продуктовый поток: ${p.productFlow.join(', ')} — дифф без класса A трек не меняет (#632)`);
L.push('');
L.push('## Права и следующий шаг');
for (const r of p.rights) L.push(`- ${r}`);
L.push('');
L.push('## Решения владельца (свежие)');
if (!p.decisions.length) L.push('- не найдены (комментарии владельца со словами решения отсутствуют)');
for (const d of p.decisions) L.push(`- ${d.at?.slice(0, 16) || ''} — ${d.head}${d.url ? ` (${d.url})` : ''}`);
L.push('');
L.push('## Материал');
if (!p.material) L.push('- ветки issue/NN-* на origin нет — материал не запушен');
else {
const m = p.material;
L.push(`- ветка \`${m.branch}\`, вершина \`${m.tip.slice(0, 12)}\`, база dev \`${m.base.slice(0, 12)}\`: впереди ${m.ahead}, позади ${m.behind}${m.behind ? ' — перед S7 ребейз (rebase-on-dev.mjs при конфликте в бандле)' : ''}`);
L.push(`- Validate на вершине: ${m.validate.status}${m.validate.url ? ` (${m.validate.url})` : ''}`);
if (m.treeMatchesVerdict === true) L.push('- дерево вне docs/reviews совпадает с материалом последнего вердикта — повторный S7 применит его без модели (#499)');
if (m.treeMatchesVerdict === false) L.push('- дерево изменилось с последнего вердикта — будет полный разбор');
}
L.push('');
L.push('## Предыдущий вердикт');
if (p.verdict.comment) L.push(`- комментарий: ${p.verdict.comment.line}${p.verdict.comment.url ? ` (${p.verdict.comment.url})` : ''}`);
if (p.verdict.doc) L.push(`- документ: \`${p.verdict.doc.name}\`, дерево \`${p.verdict.doc.tree?.slice(0, 12) || '—'}\`, запись конвейера: ${p.verdict.doc.recorded ? `${p.verdict.doc.recorded.verdict} · High ${p.verdict.doc.recorded.high}` : 'нет (документ до #499)'}`);
if (!p.verdict.comment && !p.verdict.doc) L.push('- вердиктов этапа ещё не было');
L.push('');
L.push('## AC → свидетель (по последнему документу ревью)');
if (!p.acceptance.length) L.push('- AC не распознаны: ни таблицы `| ACn |`, ни строк `ACn:` в ТЗ/теле issue');
for (const a of p.acceptance) L.push(`- **${a.id}** ${a.text} → ${a.evidence}`);
L.push('');
L.push('## Непроверенное');
L.push(p.unverified.length ? `- ${p.unverified.join(', ')}: нет записи в последнем документе ревью — свидетель нужен до S7` : '- по документу ревью все распознанные AC имеют запись');
return `${L.join('\n')}\n`;
}
// ---- сбор входов: gh + git -------------------------------------------------
function sh(cmd, args, { cwd } = {}) {
const r = spawnSync(cmd, args, { encoding: 'utf8', cwd, maxBuffer: 64 * 1024 * 1024 });
if (r.error) throw r.error;
if (r.status !== 0) throw new Error(`${cmd} ${args.join(' ')} → ${(r.stderr || '').trim()}`);
return (r.stdout || '').trim();
}
export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = process.cwd() }) {
const view = JSON.parse(sh('gh', ['issue', 'view', String(number), '--repo', repo, '--json', 'number,title,body,state,url,labels,comments']));
const labels = (view.labels || []).map((l) => l.name);
const comments = (view.comments || []).map((c) => ({ author: c.author?.login, body: c.body, createdAt: c.createdAt, url: c.url }));
const owner = repo.split('/')[0];
try { sh('git', ['fetch', '-q', 'origin', 'dev', `+refs/heads/issue/${number}-*:refs/remotes/origin/issue/${number}-*`], { cwd }); } catch { /* офлайн — читаем что есть */ }
const refs = sh('git', ['for-each-ref', '--sort=-committerdate', '--format=%(refname:lstrip=3)', `refs/remotes/origin/issue/${number}-*`], { cwd }).split('\n').filter(Boolean);
let branch = null; let reviewDocs = []; let specs = [];
if (refs[0]) {
const name = refs[0]; const ref = `origin/${name}`;
const tip = sh('git', ['rev-parse', ref], { cwd });
const base = sh('git', ['merge-base', 'origin/dev', ref], { cwd });
const ahead = Number(sh('git', ['rev-list', '--count', `origin/dev..${ref}`], { cwd }));
const behind = Number(sh('git', ['rev-list', '--count', `${ref}..origin/dev`], { cwd }));
const changedFiles = sh('git', ['diff', '--name-only', `${base}..${ref}`], { cwd }).split('\n').filter(Boolean);
const infrastructure = branchIsInfrastructure(changedFiles);
// Дерево без docs/reviews — для сравнения с якорем вердикта: git сам его не даёт,
// поэтому сравнение делается diff'ом при известном якоре (см. ниже).
const names = sh('git', ['ls-tree', '--name-only', `${ref}:docs/reviews`], { cwd }).split('\n').filter((n) => new RegExp(`-${number}-r\\d+\\.md$`).test(n));
reviewDocs = names.sort().map((n) => ({ name: n, text: sh('git', ['show', `${ref}:docs/reviews/${n}`], { cwd }) }));
const specNames = sh('git', ['ls-tree', '--name-only', `${ref}:docs/specs`], { cwd }).split('\n').filter((n) => new RegExp(`^${number}-.*\\.md$`).test(n));
specs = specNames.map((n) => ({ name: n, text: sh('git', ['show', `${ref}:docs/specs/${n}`], { cwd }) }));
const anchorTree = reviewDocs.length ? anchorTreeFrom(reviewDocs.at(-1).text) : null;
let treeWithoutReviews = null;
if (anchorTree) {
const same = spawnSync('git', ['diff', '--quiet', anchorTree, tip, '--', '.', ':!docs/reviews', ':!legacy/reviews'], { cwd });
treeWithoutReviews = same.status === 0 ? anchorTree : `differs-from-${anchorTree}`;
}
branch = { name, tip, base, ahead, behind, treeWithoutReviews, infrastructure };
}
let validate = null;
if (branch) {
try {
const runs = JSON.parse(sh('gh', ['run', 'list', '--repo', repo, '--workflow', 'validate.yml', '--commit', branch.tip, '--limit', '5', '--json', 'status,conclusion,url']));
const green = runs.find((r) => r.status === 'completed' && r.conclusion === 'success');
const running = runs.find((r) => r.status !== 'completed');
validate = green ? { status: 'зелёный', url: green.url } : running ? { status: 'идёт', url: running.url }
: runs[0] ? { status: `красный (${runs[0].conclusion})`, url: runs[0].url } : { status: 'прогона нет' };
} catch { validate = { status: 'неизвестно (gh run list недоступен)' }; }
}
return { issue: view, labels, comments, owner, branch, specs, reviewDocs, validate };
}
if (isMainModule(import.meta.url)) {
const argv = process.argv.slice(2);
const value = (name) => {
const eq = argv.find((a) => a.startsWith(`--${name}=`));
if (eq) return eq.slice(name.length + 3);
const at = argv.indexOf(`--${name}`);
return at >= 0 ? (argv[at + 1] || '') : '';
};
const number = Number(value('issue'));
if (!Number.isInteger(number) || number <= 0) { console.error('нужен --issue <номер>'); process.exit(2); }
const repo = value('repo') || 'Matysh/houseplan-card';
const packet = buildPacket(collectInputs({ number, repo }));
process.stdout.write(argv.includes('--json') ? `${JSON.stringify(packet, null, 2)}\n` : renderPacket(packet));
}