mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-04 05:41:34 +00:00
feat(process): a track:ask draft may be written during the spec review (#729)
On track:ask every spec review round is 10-45 minutes of waiting, and rule #1 kept the author idle for all of it. Rule 10 (#738) judges a class A commit by its author date, so code written in the S4-spec-review epoch was always refused: nothing told a draft written against the reviewed text from a violation. The owner allowed changing rule #1 for this (decision 2026-10-01). A draft commit carries `Spec-Draft: sha256:<issueBodyDigest(body)>`, the hash the pipeline already writes as "Тело issue" into the review document anchor. Rule 10 accepts a class A commit written in S4 only when its S4 epoch (a repeated S4 does not restart it) was closed by S5-ready, the track at the author date was ask, and the trailer equals the body of the green, High 0 SPEC-REVIEW added inside that epoch, read from the range head or origin/dev. The first failing check is the one finding: trailer format, track, how the epoch ended, the missing document with a `git fetch origin dev` hint, or both hashes and the document name. A trailer on a commit written in an allowed epoch is a warn. Without the document reader rule 10 is exactly #738; main always passes one, and it reads git only when the range holds a draft. The task packet tells S4 on ask that a local draft is allowed while the branch stays closed, prints the trailer line, and in S5/S6 names the green spec review, whether the body changed since, and the --report check before push. SPEC-REVIEW documents are a separate input (specDocs) from the branch and origin/dev, so the previous verdict and the AC witness keep their source. PROCESS.md gets §11.8 and the points that refer to it (§1, §2.4-2.6, §3 item 1, §7.2, §9, §10.2 item 10, §12); AUTHOR.md, REVIEWER.md and AGENTS.md follow, with three new key rules in process-digests. The pre-push hook fixture copies the modules process-gate now imports. Issue: #729 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
+189
-5
@@ -42,6 +42,11 @@ import { resolveValidationRange } from './validate-commit-provenance.mjs';
|
||||
// Классы изменений (PROCESS.md §1) живут в change-classes.mjs (#701): их
|
||||
// читает и хук commit-msg, который судит, нужен ли коммиту трейлер.
|
||||
import { classify } from './change-classes.mjs';
|
||||
// #729: черновик `track:ask` судится теми же функциями, что конвейер: зелёный
|
||||
// документ и «Тело issue» — по якорям review-doc-guard, трек — по меткам
|
||||
// process-track. Ни один из модулей process-gate.mjs не импортирует.
|
||||
import { anchorIssueBodyFrom, anchorVerdictFrom } from './review-doc-guard.mjs';
|
||||
import { hasTrackLabel, trackFromLabels } from './process-track.mjs';
|
||||
|
||||
export { classify };
|
||||
|
||||
@@ -111,6 +116,8 @@ export function makeCommit({
|
||||
release: one('Release'),
|
||||
baselineReviewed: one('Baseline-Reviewed'),
|
||||
baselineReviewedLocal: one('Baseline-Reviewed-Local'),
|
||||
// #729: все значения, а не первое — «ровно один» судит правило 10.
|
||||
specDrafts: all('Spec-Draft'),
|
||||
gates: one('Gates'),
|
||||
// null = вызывающий не доказал содержимое diff. Для stable release это
|
||||
// намеренно fail-closed: одного имени разрешённого version source мало.
|
||||
@@ -520,6 +527,96 @@ function statusIndexAt(events, w) {
|
||||
|
||||
const iso = (ms) => new Date(ms).toISOString();
|
||||
|
||||
// #729: черновик кода во время ревью ТЗ (PROCESS.md §11.8). Статус, в котором
|
||||
// он пишется, трейлер и его значение — `issueBodyDigest` тела issue, тот же
|
||||
// хеш, что конвейер пишет в «Тело issue:» документа ревью.
|
||||
export const DRAFT_STATUS = 'S4-spec-review';
|
||||
export const SPEC_DRAFT_VALUE = /^sha256:[0-9a-f]{64}$/;
|
||||
|
||||
// Трек на момент `w`: последнее событие трековой метки (`track:*`, прежние
|
||||
// `small`/`trivial`) с `at ≤ w`, трек — по одной этой метке. Событий нет —
|
||||
// `ask`: продуктовая задача без метки (§5.1).
|
||||
function trackAt(rawEvents, w) {
|
||||
const marks = rawEvents
|
||||
.filter((e) => e && e.at && hasTrackLabel([e.label]))
|
||||
.map((e) => ({ label: e.label, at: Date.parse(e.at) }))
|
||||
.filter((e) => Number.isFinite(e.at) && e.at <= w)
|
||||
.sort((a, b) => a.at - b.at);
|
||||
const last = marks.at(-1);
|
||||
return last
|
||||
? { track: trackFromLabels([last.label]), label: last.label, at: last.at }
|
||||
: { track: 'ask', label: null, at: null };
|
||||
}
|
||||
|
||||
// Эпоха `S4` вокруг события `at`: начало — первое `S4` непрерывной серии
|
||||
// (повторная постановка, reconcile #555, эпоху заново не начинает), конец —
|
||||
// первое следующее статусное событие с другой меткой, либо `null`.
|
||||
function draftEpoch(events, at) {
|
||||
let first = at;
|
||||
while (first > 0 && events[first - 1].label === DRAFT_STATUS) first -= 1;
|
||||
const close = events.slice(at + 1).find((e) => e.label !== DRAFT_STATUS) ?? null;
|
||||
return { start: events[first].at, close };
|
||||
}
|
||||
|
||||
/**
|
||||
* Зелёный SPEC-REVIEW эпохи `[start, end]` (#729): запись конвейера `green` с
|
||||
* High 0, есть «Тело issue:», документ добавлен (`addedAt`, дата автора
|
||||
* добавившего коммита) внутри окна. Из подходящих — наибольший заход.
|
||||
*/
|
||||
export function greenSpecReviewOf(docs, nn, start, end) {
|
||||
const name = new RegExp(`^SPEC-REVIEW-${nn}-r(\\d+)\\.md$`);
|
||||
let best = null;
|
||||
for (const doc of docs || []) {
|
||||
const m = name.exec(String(doc?.name ?? ''));
|
||||
if (!m) continue;
|
||||
const recorded = anchorVerdictFrom(doc.text);
|
||||
if (!recorded || recorded.verdict !== 'green' || recorded.high !== 0) continue;
|
||||
const body = anchorIssueBodyFrom(doc.text);
|
||||
if (!body) continue;
|
||||
const added = Date.parse(doc.addedAt);
|
||||
if (!Number.isFinite(added) || added < start || added > end) continue;
|
||||
const round = Number(m[1]);
|
||||
if (!best || round > best.round) best = { name: doc.name, round, body, addedAt: added };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// Проверки чернового коммита по порядку (К3 п.2 #729); находка одна — по первой
|
||||
// невыполненной. `null` — коммит принят как написанный в разрешённой эпохе.
|
||||
function judgeDraft({ c, nn, events, at, rawEvents, rangeHead, docsOf }) {
|
||||
const wrote = Date.parse(c.authorDate);
|
||||
const fail = (msg) => ({ level: 'fail', rule: 10, sha: c.short, msg });
|
||||
const lead = `issue #${nn}: коммит класса A написан ${c.authorDate} в ${DRAFT_STATUS}`;
|
||||
const drafts = c.specDrafts ?? [];
|
||||
if (drafts.length !== 1 || !SPEC_DRAFT_VALUE.test(drafts[0])) {
|
||||
return fail(`${lead}: трейлер \`Spec-Draft\` должен быть ровно один, вида \`sha256:<64 hex>\` `
|
||||
+ `(трейлеров ${drafts.length}) (§11.8)`);
|
||||
}
|
||||
const track = trackAt(rawEvents, wrote);
|
||||
if (track.track !== 'ask') {
|
||||
return fail(`${lead} на треке ${track.track} (${track.label} с ${iso(track.at)}): `
|
||||
+ 'черновик разрешён только на `track:ask` (§11.8)');
|
||||
}
|
||||
const epoch = draftEpoch(events, at);
|
||||
if (!epoch.close || epoch.close.label !== 'S5-ready') {
|
||||
const closed = epoch.close ? `закрыта ${epoch.close.label} ${iso(epoch.close.at)}` : 'ещё не закрыта';
|
||||
return fail(`${lead}: эпоха ${DRAFT_STATUS} с ${iso(epoch.start)} ${closed} — `
|
||||
+ 'черновик принимается только из эпохи, которую закрыл `S5-ready` (§11.8)');
|
||||
}
|
||||
const green = greenSpecReviewOf(docsOf(), nn, epoch.start, epoch.close.at);
|
||||
if (!green) {
|
||||
return fail(`${lead}: эпоха ${iso(epoch.start)}…${iso(epoch.close.at)} — зелёный \`SPEC-REVIEW-${nn}-r*\` `
|
||||
+ `этой эпохи не найден ни в ${rangeHead}, ни в origin/dev — \`git fetch origin dev\` и повторить (§11.8)`);
|
||||
}
|
||||
const draft = drafts[0].slice('sha256:'.length);
|
||||
if (draft !== green.body) {
|
||||
return fail(`${lead}: трейлер \`Spec-Draft\` sha256:${draft.slice(0, 12)} не равен «Тело issue» `
|
||||
+ `sha256:${green.body.slice(0, 12)} из ${green.name} — ТЗ правилось — работа переделывается после S5, `
|
||||
+ 'дата автора не переписывается (§12)');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 10. DoR по моменту коммита (#311, #738). Правило 8 читает ТЕКУЩУЮ метку
|
||||
// issue: нарушение «код написан вне статуса разработки» становится невидимым,
|
||||
// как только статус штатно продвигается. Здесь статус задачи сверяется с
|
||||
@@ -532,8 +629,15 @@ const iso = (ms) => new Date(ms).toISOString();
|
||||
// authorDate переживает ребейзы конвейера — окно нарушения не закрывается.
|
||||
// Проверка вторичная к правилу 8, поэтому недоступный timeline — warn, а не
|
||||
// fail: основная fail-closed проверка статуса остаётся за правилом 8.
|
||||
//
|
||||
// #729: одно исключение поверх эпох — черновик `track:ask` (§11.8). Коммит,
|
||||
// написанный в `S4-spec-review` с трейлером `Spec-Draft`, принимается, если
|
||||
// эпоху `S4` закрыл `S5-ready`, трек на момент написания — `ask`, а трейлер
|
||||
// равен «Тело issue» зелёного SPEC-REVIEW этой эпохи. Документы читает
|
||||
// `specReviews` (`{ head, read(nn) }`, см. gitSpecReviewReader); не передан —
|
||||
// исключение выключено, и находки с текстами равны #738.
|
||||
export function checkCommitEraStatuses(
|
||||
commits, timelineRunner, { allowed = ALLOWED_STATUS } = {},
|
||||
commits, timelineRunner, { allowed = ALLOWED_STATUS, specReviews = null } = {},
|
||||
) {
|
||||
const out = [];
|
||||
const byIssue = new Map();
|
||||
@@ -567,11 +671,40 @@ export function checkCommitEraStatuses(
|
||||
});
|
||||
continue;
|
||||
}
|
||||
// #729: документы issue читаются один раз и только для чернового коммита.
|
||||
let issueDocs = null;
|
||||
const docsOf = () => {
|
||||
if (issueDocs === null) issueDocs = specReviews.read(nn) ?? [];
|
||||
return issueDocs;
|
||||
};
|
||||
for (const c of list) {
|
||||
const wrote = Date.parse(c.authorDate);
|
||||
if (!Number.isFinite(wrote)) continue;
|
||||
const at = statusIndexAt(events, wrote);
|
||||
if (at >= 0 && events[at].ready) continue;
|
||||
const drafts = c.specDrafts ?? [];
|
||||
if (at >= 0 && events[at].ready) {
|
||||
// #729: коммит законен по #738, трейлер — шум; вероятно, он переехал
|
||||
// вместе с кодом при переносе черновика.
|
||||
if (specReviews && drafts.length) {
|
||||
out.push({
|
||||
level: 'warn', rule: 10, sha: c.short,
|
||||
msg: `issue #${nn}: трейлер \`Spec-Draft\` на коммите, написанном в ${events[at].label}: `
|
||||
+ `черновиком считается только коммит из \`${DRAFT_STATUS}\` (§11.8)`,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const inDraftStatus = Boolean(specReviews) && at >= 0 && events[at].label === DRAFT_STATUS;
|
||||
if (inDraftStatus && drafts.length) {
|
||||
const finding = judgeDraft({
|
||||
c, nn, events, at, rawEvents: r.events, rangeHead: specReviews.head ?? '<head>', docsOf,
|
||||
});
|
||||
if (finding) out.push(finding);
|
||||
continue;
|
||||
}
|
||||
const tail = inDraftStatus
|
||||
? `; написан в \`${DRAFT_STATUS}\` — черновик \`track:ask\` принимается только с трейлером \`Spec-Draft\` (§11.8)`
|
||||
: '';
|
||||
let lastReady = -1;
|
||||
for (let i = 0; i < at; i += 1) if (events[i].ready) lastReady = i;
|
||||
if (lastReady < 0) {
|
||||
@@ -579,7 +712,7 @@ export function checkCommitEraStatuses(
|
||||
level: 'fail', rule: 10, sha: c.short,
|
||||
msg: `issue #${nn}: коммит класса A написан ${c.authorDate}, `
|
||||
+ `до первого достижения задачей статуса из ${allowed.join('/')} `
|
||||
+ `(${iso(firstReady.at)}) — код раньше «Готово к разработке» (§12)`,
|
||||
+ `(${iso(firstReady.at)}) — код раньше «Готово к разработке» (§12)${tail}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -591,7 +724,7 @@ export function checkCommitEraStatuses(
|
||||
level: 'fail', rule: 10, sha: c.short,
|
||||
msg: `issue #${nn}: коммит класса A написан ${c.authorDate} в ${events[at].label} — `
|
||||
+ `после возврата ${iso(returned.at)} и до повторного «Готово к разработке» `
|
||||
+ `(${nextReady ? iso(nextReady.at) : 'ещё не достигнут'}) — код вне статуса разработки (§12)`,
|
||||
+ `(${nextReady ? iso(nextReady.at) : 'ещё не достигнут'}) — код вне статуса разработки (§12)${tail}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -640,6 +773,54 @@ function ghTimelineRunner(nwo, bin) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Документы SPEC-REVIEW из git для исключения #729 (§11.8): вершина
|
||||
* проверяемого диапазона и `refs/remotes/origin/dev`, если она есть. На issue
|
||||
* SPEC-REVIEW лежит либо в ветке задачи, либо прямо в `dev` (ветки до `S5` у
|
||||
* `ask` обычно нет), поэтому нужны обе ссылки. `addedAt` — дата автора
|
||||
* коммита, добавившего путь на той ссылке, где документ найден: она переживает
|
||||
* ребейзы конвейера. Одно имя на обеих ссылках — одна запись. Чтение ленивое:
|
||||
* ни одной команды git, пока правило 10 не спросило документы issue, и один
|
||||
* раз на issue.
|
||||
*/
|
||||
export function gitSpecReviewReader({ repo = process.cwd(), head = 'HEAD', run = spawnSync } = {}) {
|
||||
const git = (args) => {
|
||||
const r = run('git', ['-C', repo, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
||||
return r && r.status === 0 ? r.stdout : null;
|
||||
};
|
||||
let refs = null;
|
||||
const cache = new Map();
|
||||
return {
|
||||
head,
|
||||
read(nn) {
|
||||
if (cache.has(nn)) return cache.get(nn);
|
||||
if (!refs) {
|
||||
refs = [head];
|
||||
if (git(['rev-parse', '--verify', '-q', 'refs/remotes/origin/dev']) !== null) refs.push('refs/remotes/origin/dev');
|
||||
}
|
||||
const name = new RegExp(`^docs/reviews/(SPEC-REVIEW-${nn}-r\\d+\\.md)$`);
|
||||
const byName = new Map();
|
||||
for (const ref of refs) {
|
||||
const listing = git(['ls-tree', '--name-only', ref, 'docs/reviews/']);
|
||||
if (listing === null) continue;
|
||||
for (const path of listing.split('\n').map((s) => s.trim())) {
|
||||
const m = name.exec(path);
|
||||
if (!m || byName.has(m[1])) continue;
|
||||
byName.set(m[1], {
|
||||
name: m[1],
|
||||
ref,
|
||||
text: git(['show', `${ref}:${path}`]) ?? '',
|
||||
addedAt: (git(['log', '-1', '--diff-filter=A', '--format=%aI', ref, '--', path]) ?? '').trim(),
|
||||
});
|
||||
}
|
||||
}
|
||||
const docs = [...byName.values()];
|
||||
cache.set(nn, docs);
|
||||
return docs;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function ghRunner(nwo, bin) {
|
||||
return (nn) => {
|
||||
const r = spawnSync(bin, ['issue', 'view', String(nn), '--repo', nwo, '--json', 'number,state,labels,body'],
|
||||
@@ -833,10 +1014,13 @@ function main(argv) {
|
||||
process.env.HP_REPO ?? 'Matysh/houseplan-card', process.env.GH_BIN ?? 'gh',
|
||||
);
|
||||
const timelineCache = new Map();
|
||||
// #729: читатель SPEC-REVIEW передаётся всегда; git он трогает, только
|
||||
// когда в диапазоне есть черновой коммит. Вершина — правая часть --range.
|
||||
const rangeHead = /^(.*?)\.\.\.?(.+)$/.exec(range)?.[2] ?? range;
|
||||
findings.push(...checkCommitEraStatuses(statusCommits, (nn) => {
|
||||
if (!timelineCache.has(nn)) timelineCache.set(nn, timelineRunner(nn));
|
||||
return timelineCache.get(nn);
|
||||
}, { allowed }));
|
||||
}, { allowed, specReviews: gitSpecReviewReader({ repo, head: rangeHead }) }));
|
||||
}
|
||||
labelsOf = (nn) => {
|
||||
const r = cached(nn);
|
||||
|
||||
+119
-13
@@ -8,10 +8,10 @@
|
||||
// Скрипт делает эту сборку детерминированно и печатает один markdown-пакет:
|
||||
//
|
||||
// issue · статус и трек · трек: основание, лимит, ребейз (#707) · что можно
|
||||
// делать в этом статусе · решения владельца · материал (ветка, SHA, база,
|
||||
// Validate) · следующий шаг по ветке · риск по участкам · обязательные
|
||||
// проверки · changelog и визуальное свидетельство · предыдущий вердикт ·
|
||||
// AC → свидетель · непроверенное
|
||||
// делать в этом статусе · черновик во время ревью ТЗ (#729) · решения
|
||||
// владельца · материал (ветка, SHA, база, Validate) · следующий шаг по
|
||||
// ветке · риск по участкам · обязательные проверки · changelog и визуальное
|
||||
// свидетельство · предыдущий вердикт · AC → свидетель · непроверенное
|
||||
//
|
||||
// Источник правды остаётся GitHub и git: пакет ничего не пишет и ничего не
|
||||
// решает. Все чтения инъектируемы — `buildPacket(inputs)` чист и покрыт тестами.
|
||||
@@ -20,7 +20,9 @@
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { isMainModule } from './spawn-portable.mjs';
|
||||
import { anchorTreeFrom, anchorVerdictFrom, verdictDeclaration } from './review-doc-guard.mjs';
|
||||
import {
|
||||
anchorIssueBodyFrom, anchorTreeFrom, anchorVerdictFrom, issueBodyDigest, verdictDeclaration,
|
||||
} from './review-doc-guard.mjs';
|
||||
import { classify } from './process-gate.mjs';
|
||||
import {
|
||||
classifyRisk, cycleLimit, hasTrackLabel, rebaseBeforeReview, riskClassLine, trackFromLabels, trackOrigin,
|
||||
@@ -30,18 +32,28 @@ import { selectSmokes } from './smoke-select.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 } = {}) {
|
||||
export function rightsFor(status, labels = [], { infrastructure = false, infrastructureHint = false, track = null } = {}) {
|
||||
const blocked = labels.includes('blocked');
|
||||
const exhausted = labels.includes('review-4');
|
||||
const code = !infrastructure && ['S5-ready', 'S6-in-progress', 'S7-code-review'].includes(status);
|
||||
// #729: на `track:ask` в `S4-spec-review` локальный черновик разрешён (§11.8),
|
||||
// в ветку код по-прежнему только после `S5`; под blocked/review-4 — нет.
|
||||
const specReview = !infrastructure && status === 'S4-spec-review' && track === 'ask';
|
||||
const draft = specReview && !blocked && !exhausted;
|
||||
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 (draft) {
|
||||
lines.push('продуктовый код в ветку — НЕЛЬЗЯ до `S5` (правило №1)');
|
||||
lines.push('черновик локально — МОЖНО (§11.8): ветка `issue/NN-slug` не пушится, трейлер — в разделе «Черновик»');
|
||||
} else {
|
||||
lines.push(code
|
||||
? 'продуктовый код трогать МОЖНО (правило №1)'
|
||||
: infrastructure
|
||||
? 'файлы класса A трогать НЕЛЬЗЯ; инфраструктурную реализацию МОЖНО вести сразу по issue (#562)'
|
||||
: 'продуктовый код трогать НЕЛЬЗЯ: статус не S5/S6/S7 (правило №1)');
|
||||
}
|
||||
if (specReview && !draft) lines.push(`черновик не ведётся (§11.8): ${exhausted ? 'review-4' : 'blocked'}`);
|
||||
if (infrastructureHint) {
|
||||
lines.push('метка infra — только подсказка, не доказательство и не право: до ветки проверь предполагаемые пути; без class A начинай сразу, при любом class A нужен продуктовый S-flow (#562)');
|
||||
}
|
||||
@@ -130,12 +142,64 @@ export function branchIsInfrastructure(changedFiles = []) {
|
||||
return material.length > 0 && !material.some((name) => classify(name) === 'A');
|
||||
}
|
||||
|
||||
/**
|
||||
* Последний зелёный SPEC-REVIEW задачи (#729): запись конвейера `green` с
|
||||
* High 0 и строкой «Тело issue:» — те же якоря, что читает гейт. Из
|
||||
* подходящих — наибольший заход; нет — `null`.
|
||||
*/
|
||||
export function latestGreenSpecReview(specDocs = [], number = null) {
|
||||
const name = new RegExp(`^SPEC-REVIEW-${number ?? '\\d+'}-r(\\d+)\\.md$`);
|
||||
let best = null;
|
||||
for (const doc of specDocs) {
|
||||
const m = name.exec(String(doc?.name ?? ''));
|
||||
if (!m) continue;
|
||||
const recorded = anchorVerdictFrom(doc.text);
|
||||
if (!recorded || recorded.verdict !== 'green' || recorded.high !== 0) continue;
|
||||
const body = anchorIssueBodyFrom(doc.text);
|
||||
if (!body) continue;
|
||||
const round = Number(m[1]);
|
||||
if (!best || round > best.round) best = { doc: doc.name, round, body };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Черновик кода во время ревью ТЗ (#729, PROCESS.md §11.8): можно ли вести его
|
||||
* сейчас, строка трейлера и зелёное ревью ТЗ, с которым гейт сверит черновые
|
||||
* коммиты. Хеш — `issueBodyDigest`, та же функция, что пишет «Тело issue:» в
|
||||
* якорь документа ревью.
|
||||
*/
|
||||
export function specDraftState({ status = null, labels = [], track = null, body = '', specDocs = [], number = null } = {}) {
|
||||
const bodyNow = issueBodyDigest(body ?? '');
|
||||
const stop = labels.includes('review-4') ? 'review-4' : labels.includes('blocked') ? 'blocked' : null;
|
||||
let allowed = false;
|
||||
let reason;
|
||||
if (status !== 'S4-spec-review') reason = `статус ${status || 'без S-метки'}: черновик ведётся только в S4-spec-review (§11.8)`;
|
||||
else if (track !== 'ask') reason = `трек ${track}: черновик только на track:ask (§11.8)`;
|
||||
else if (stop) reason = `${stop}: черновик не ведётся (§11.8)`;
|
||||
else {
|
||||
allowed = true;
|
||||
reason = 'track:ask в S4-spec-review: черновик можно вести локально (§11.8)';
|
||||
}
|
||||
const green = latestGreenSpecReview(specDocs, number);
|
||||
return {
|
||||
allowed,
|
||||
reason,
|
||||
trailer: allowed ? `Spec-Draft: sha256:${bodyNow}` : null,
|
||||
green,
|
||||
bodyNow,
|
||||
bodyChanged: green ? green.body !== bodyNow : null,
|
||||
};
|
||||
}
|
||||
|
||||
const PRE_CODE_STATUSES = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready'];
|
||||
|
||||
// Трек по меткам — одна функция на конвейер и пакет (#696): process-track.mjs.
|
||||
// С #707 оттуда же основание трека, лимит циклов, политика ребейза и риск по
|
||||
// изменённым участкам: пакет не держит своей копии правила.
|
||||
export { classifyRisk, cycleLimit, hasTrackLabel, rebaseBeforeReview, trackFromLabels, trackOrigin };
|
||||
// Хеш тела issue — одна функция с конвейером и гейтом (#729): review-doc-guard.mjs.
|
||||
export { issueBodyDigest };
|
||||
|
||||
/** Зеркала junction limits: правка любого требует parity (§8, #548). */
|
||||
export const JUNCTION_MIRRORS = Object.freeze([
|
||||
@@ -254,6 +318,7 @@ export function productFlowEvidence({ status = null, labels = [], issue = {}, sp
|
||||
export function buildPacket(inputs) {
|
||||
const {
|
||||
issue, labels = [], comments = [], owner = 'Matysh', branch = null, specs = [], reviewDocs = [], validate = null,
|
||||
specDocs = [],
|
||||
} = inputs;
|
||||
const status = STATUS_LABELS.find((l) => labels.includes(l)) || null;
|
||||
// Трек сначала определяется статусом и историей issue (#632): прошедшая
|
||||
@@ -300,7 +365,13 @@ export function buildPacket(inputs) {
|
||||
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, trackDetail, labels, productFlow, rights: rightsFor(status, labels, { infrastructure, infrastructureHint }),
|
||||
status, track, trackDetail, labels, productFlow,
|
||||
rights: rightsFor(status, labels, { infrastructure, infrastructureHint, track: infrastructure ? null : origin.track }),
|
||||
// #729: свой вход `specDocs` — lastVerdict, AC → свидетель и признаки
|
||||
// продуктового потока по-прежнему читают только reviewDocs.
|
||||
specDraft: specDraftState({
|
||||
status, labels, track: infrastructure ? null : origin.track, body: issue.body, specDocs, number: issue.number,
|
||||
}),
|
||||
decisions: ownerDecisions(comments, owner),
|
||||
material: branch ? {
|
||||
branch: branch.name, tip: branch.tip, base: branch.base, ahead: branch.ahead, behind: branch.behind,
|
||||
@@ -337,6 +408,26 @@ export function renderPacket(p) {
|
||||
L.push('## Права и следующий шаг');
|
||||
for (const r of p.rights) L.push(`- ${r}`);
|
||||
L.push('');
|
||||
const draft = p.specDraft;
|
||||
const askTrack = p.trackDetail?.track === 'ask';
|
||||
if (draft && askTrack && p.status === 'S4-spec-review') {
|
||||
L.push('## Черновик (#729)');
|
||||
if (draft.allowed) {
|
||||
L.push(`- трейлер каждого чернового коммита: \`${draft.trailer}\``);
|
||||
L.push('- ветку не пушить до `S5`; `S6` — после зелёного ревью ТЗ; комментарий «Черновик:» (§7.2); черновик занимает слот WIP');
|
||||
L.push('- жёлтый или красный вердикт — черновик остановить: коммиты по прежнему тексту гейт не примет');
|
||||
} else L.push(`- ${draft.reason}`);
|
||||
L.push('');
|
||||
}
|
||||
if (draft?.green && askTrack && (p.status === 'S5-ready' || p.status === 'S6-in-progress')) {
|
||||
L.push('## Черновик (#729)');
|
||||
L.push(`- зелёное ревью ТЗ: \`${draft.green.doc}\` · тело \`sha256:${draft.green.body.slice(0, 12)}\` — черновые коммиты принимаются с этим хешем`);
|
||||
L.push(draft.bodyChanged
|
||||
? '- тело issue сейчас: изменилось после зелёного ревью ТЗ (ревьюер кода получит находку, #517)'
|
||||
: '- тело issue сейчас: совпадает');
|
||||
L.push('- проверка до push: `node scripts/process-gate.mjs --range origin/dev..HEAD --issues --report`');
|
||||
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})` : ''}`);
|
||||
@@ -449,6 +540,21 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr
|
||||
changedFiles, diff, commits, smokes, mergeClean: merge.clean, conflicts: merge.conflicts,
|
||||
};
|
||||
}
|
||||
// #729: SPEC-REVIEW — отдельный вход `specDocs` из ветки на origin и из
|
||||
// `origin/dev`: на ask документ ревью ТЗ обычно ложится прямо в dev. Источник
|
||||
// reviewDocs (lastVerdict, AC → свидетель) не меняется.
|
||||
const specByName = new Map();
|
||||
for (const ref of [...(refs[0] ? [`origin/${refs[0]}`] : []), 'origin/dev']) {
|
||||
let names = [];
|
||||
try {
|
||||
names = sh('git', ['ls-tree', '--name-only', `${ref}:docs/reviews`], { cwd }).split('\n')
|
||||
.filter((n) => new RegExp(`^SPEC-REVIEW-${number}-r\\d+\\.md$`).test(n));
|
||||
} catch { continue; }
|
||||
for (const n of names) {
|
||||
if (!specByName.has(n)) specByName.set(n, { name: n, text: sh('git', ['show', `${ref}:docs/reviews/${n}`], { cwd }) });
|
||||
}
|
||||
}
|
||||
const specDocs = [...specByName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
let validate = null;
|
||||
if (branch) {
|
||||
try {
|
||||
@@ -459,7 +565,7 @@ export function collectInputs({ number, repo = 'Matysh/houseplan-card', cwd = pr
|
||||
: 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 };
|
||||
return { issue: view, labels, comments, owner, branch, specs, reviewDocs, specDocs, validate };
|
||||
}
|
||||
|
||||
if (isMainModule(import.meta.url)) {
|
||||
|
||||
Reference in New Issue
Block a user