diff --git a/PROCESS.md b/PROCESS.md index c36d3b98..f9b0aa8b 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1186,6 +1186,22 @@ Validate — провенанс, процессный гейт, `no-new-any`, `n же диапазон, а hotfix поверх кандидата — от кандидата. Реализация — `scripts/classify-base.mjs --mode=range`, тесты — `test/promotion-range.test.mjs`. +С тем же `--issues`, по timeline issue (#311, #738): + +10. статус задачи **на момент написания** кода. Правило 8 видит только текущую + метку, поэтому каждый коммит класса A, кроме релизного, сверяется по + `authorDate` со шкалой `labeled`-событий статусных меток: эпоху «можно + трогать код» открывает метка из множества проверки 8 (с `--no-merged` — без + `S8-merged`), закрывают `S1-new`, `S2-analysis`, `S3-spec` и + `S4-spec-review`, остальные метки (`blocked`, `track:*`, `review-4`) её не + меняют; статус на момент коммита — последнее статусное событие не позже + `authorDate`. Отказ — код, написанный до первого «Готово к разработке» или + после возврата `S5+ → S3/S4` (`reclassify`, ручной возврат) и до повторной + готовности: в эпоху `S4` код класса A не пишется, как и в `S3`. Коммиты, + написанные до возврата, остаются законными. Без timeline или без единого + события из множества проверки 8 — предупреждение, а не отказ: проверка + вторична, fail closed по статусу держит правило 8. + Не реализовано и остаётся долгом: 9. `npm run release:prerelease -- --issues=…` не проверяет, есть ли у issue diff --git a/scripts/process-gate.mjs b/scripts/process-gate.mjs index d72260a8..703452bc 100644 --- a/scripts/process-gate.mjs +++ b/scripts/process-gate.mjs @@ -4,7 +4,7 @@ // // node scripts/process-gate.mjs --range .. // node scripts/process-gate.mjs --github-range # диапазон из события CI -// node scripts/process-gate.mjs --issues # + проверка 8 через gh +// node scripts/process-gate.mjs --issues # + проверки 8 и 10 через gh // node scripts/process-gate.mjs --json # машинный вывод // node scripts/process-gate.mjs --report # печатать, но не краснеть // @@ -494,11 +494,41 @@ export function checkIssueStatuses( return out; } -// 10. DoR по моменту коммита (#311). Правило 8 читает ТЕКУЩУЮ метку issue: -// нарушение «код написан до Готово к разработке» становится невидимым, как -// только статус штатно продвигается. Здесь метка сверяется с моментом -// НАПИСАНИЯ кода: authorDate коммита класса A не может предшествовать первому -// достижению issue разрешённого статуса (labeled-событие из timeline). +// Метки статуса до готовности (#738): каждая закрывает эпоху «можно трогать +// код», которую открывает метка из множества проверки 8. Прочие метки +// (`blocked`, `track:*`, `review-4`, при `--no-merged` и `S8-merged`) эпоху не +// меняют. Неизвестные метки вида `S\d-…` — тоже. +export const PRE_READY_STATUS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review']; + +// #738: шкала статуса задачи — только статусные события, по времени. Сортировка +// устойчивая: при равном времени остаётся порядок timeline. +function statusEvents(events, allowed) { + return events + .filter((e) => e && e.at && (allowed.includes(e.label) || PRE_READY_STATUS.includes(e.label))) + .map((e) => ({ label: e.label, at: Date.parse(e.at), ready: allowed.includes(e.label) })) + .filter((e) => Number.isFinite(e.at)) + .sort((a, b) => a.at - b.at); +} + +// Индекс статуса на момент `w` — последнее событие с `at ≤ w`; -1, если +// статусных событий до `w` нет. +function statusIndexAt(events, w) { + let idx = -1; + for (let i = 0; i < events.length && events[i].at <= w; i += 1) idx = i; + return idx; +} + +const iso = (ms) => new Date(ms).toISOString(); + +// 10. DoR по моменту коммита (#311, #738). Правило 8 читает ТЕКУЩУЮ метку +// issue: нарушение «код написан вне статуса разработки» становится невидимым, +// как только статус штатно продвигается. Здесь статус задачи сверяется с +// моментом НАПИСАНИЯ кода: на authorDate коммита класса A задача должна быть в +// эпохе из `allowed`. Эпохи строятся по labeled-событиям timeline: `allowed` +// открывает, PRE_READY_STATUS закрывает. Так ловится и код до первого +// «Готово к разработке», и код, написанный после возврата `S5+ → S3/S4` +// (`reclassify` #726, ручной возврат) и запушенный после нового `S5`; коммиты, +// написанные до возврата, остаются законными. // authorDate переживает ребейзы конвейера — окно нарушения не закрывается. // Проверка вторичная к правилу 8, поэтому недоступный timeline — warn, а не // fail: основная fail-closed проверка статуса остаётся за правилом 8. @@ -526,12 +556,9 @@ export function checkCommitEraStatuses( }); continue; } - const readyAt = r.events - .filter((e) => allowed.includes(e.label) && e.at) - .map((e) => Date.parse(e.at)) - .filter(Number.isFinite) - .sort((a, b) => a - b)[0]; - if (readyAt === undefined) { + const events = statusEvents(r.events, allowed); + const firstReady = events.find((e) => e.ready); + if (firstReady === undefined) { // Правило 8 уже требует текущий разрешённый статус; отсутствие событий // при живом статусе — неполный timeline, честный warn. out.push({ @@ -542,14 +569,30 @@ export function checkCommitEraStatuses( } for (const c of list) { const wrote = Date.parse(c.authorDate); - if (Number.isFinite(wrote) && wrote < readyAt) { + if (!Number.isFinite(wrote)) continue; + const at = statusIndexAt(events, wrote); + if (at >= 0 && events[at].ready) continue; + let lastReady = -1; + for (let i = 0; i < at; i += 1) if (events[i].ready) lastReady = i; + if (lastReady < 0) { out.push({ level: 'fail', rule: 10, sha: c.short, msg: `issue #${nn}: коммит класса A написан ${c.authorDate}, ` + `до первого достижения задачей статуса из ${allowed.join('/')} ` - + `(${new Date(readyAt).toISOString()}) — код раньше «Готово к разработке» (§12)`, + + `(${iso(firstReady.at)}) — код раньше «Готово к разработке» (§12)`, }); + continue; } + // Возврат — первое закрывающее событие после последней готовности; + // повторная готовность — первое событие `allowed` после написания. + const returned = events[lastReady + 1]; + const nextReady = events.slice(at + 1).find((e) => e.ready); + out.push({ + level: 'fail', rule: 10, sha: c.short, + msg: `issue #${nn}: коммит класса A написан ${c.authorDate} в ${events[at].label} — ` + + `после возврата ${iso(returned.at)} и до повторного «Готово к разработке» ` + + `(${nextReady ? iso(nextReady.at) : 'ещё не достигнут'}) — код вне статуса разработки (§12)`, + }); } } return out; diff --git a/test/process-gate.test.mjs b/test/process-gate.test.mjs index f0c8b275..d1db6c38 100644 --- a/test/process-gate.test.mjs +++ b/test/process-gate.test.mjs @@ -902,3 +902,159 @@ test('rule 10 pins DoR to the commit author date, not to the current label (#311 }); assert.deepEqual(checkCommitEraStatuses([doc], timeline(ready)), []); }); + +// #738: правило 10 судит статус задачи на authorDate коммита по эпохам статуса. +// Возврат `S5+ → S3/S4` (reclassify #726, ручной) закрывает эпоху разработки: +// код, написанный в S3/S4 и запушенный после нового S5, — отказ. +const eraCode = (iso, { sha = 'a'.repeat(40), files = ['src/houseplan-card.ts'] } = {}) => makeCommit({ + sha, subject: 'feat: work (#738)', body: 'Issue: #738\nUser-Visible: no', files, authorDate: iso, +}); +const eraTimeline = (events) => () => ({ ok: true, events }); +const H = 3600 * 1000; +const at = (base, hours) => new Date(Date.parse(base) + hours * H).toISOString(); +const T1 = '2026-09-01T10:00:00.000Z'; // S5-ready +const T2 = '2026-09-02T10:00:00.000Z'; // S6-in-progress +const T3 = '2026-09-03T10:00:00.000Z'; // S7-code-review +const T4 = '2026-09-04T10:00:00.000Z'; // S3-spec — возврат +const T5 = '2026-09-05T10:00:00.000Z'; // S4-spec-review +const T6 = '2026-09-06T10:00:00.000Z'; // S5-ready — повторная готовность +const RETURN_ROUTE = [ + { label: 'S5-ready', at: T1 }, + { label: 'S6-in-progress', at: T2 }, + { label: 'S7-code-review', at: T3 }, + { label: 'S3-spec', at: T4 }, + { label: 'S4-spec-review', at: T5 }, + { label: 'S5-ready', at: T6 }, +]; +const era = (iso, events = RETURN_ROUTE, options) => + checkCommitEraStatuses([eraCode(iso)], eraTimeline(events), options); + +test('#738 AC1: rule 10 judges the status at authorDate — code written after a return is out of the dev era', () => { + // Written in S6, before the return — still legitimate after it. + assert.deepEqual(era(at(T2, 1)), []); + + // Written in S3 after the return, pushed after the new S5 — fail naming S3-spec and t4. + const inS3 = era(at(T4, 1)); + assert.equal(inS3.length, 1); + assert.equal(inS3[0].level, 'fail'); + assert.equal(inS3[0].rule, 10); + assert.match(inS3[0].msg, /в S3-spec/); + assert.ok(inS3[0].msg.includes(`после возврата ${T4}`), inS3[0].msg); + assert.ok(inS3[0].msg.includes(`(${T6})`), inS3[0].msg); + + // S4 is the same pre-ready era as S3: the return time stays t4. + const inS4 = era(at(T5, 1)); + assert.equal(inS4.length, 1); + assert.equal(inS4[0].level, 'fail'); + assert.equal(inS4[0].rule, 10); + assert.match(inS4[0].msg, /в S4-spec-review/); + assert.ok(inS4[0].msg.includes(`после возврата ${T4}`), inS4[0].msg); + assert.ok(inS4[0].msg.includes(`(${T6})`), inS4[0].msg); + + // Written after the repeated readiness — clean. + assert.deepEqual(era(at(T6, 1)), []); + + // Written before the first readiness — the old text. + const early = era(at(T1, -1)); + assert.equal(early.length, 1); + assert.equal(early[0].level, 'fail'); + assert.equal(early[0].rule, 10); + assert.match(early[0].msg, /до первого достижения задачей статуса из/); + assert.ok(early[0].msg.includes(`(${T1})`), early[0].msg); + assert.doesNotMatch(early[0].msg, /после возврата/); +}); + +test('#738 AC1: two returns are judged each by its own readiness; a return without a new S5 is "not reached yet"', () => { + const T7 = '2026-09-07T10:00:00.000Z'; // S6 + const T8 = '2026-09-08T10:00:00.000Z'; // S3-spec — второй возврат + const T9 = '2026-09-09T10:00:00.000Z'; // S4-spec-review + const T10 = '2026-09-10T10:00:00.000Z'; // S6-in-progress — готовность без S5 + const twice = [ + ...RETURN_ROUTE, + { label: 'S6-in-progress', at: T7 }, + { label: 'S3-spec', at: T8 }, + { label: 'S4-spec-review', at: T9 }, + { label: 'S6-in-progress', at: T10 }, + ]; + const first = era(at(T5, 1), twice); + assert.equal(first.length, 1); + assert.ok(first[0].msg.includes(`после возврата ${T4}`), first[0].msg); + assert.ok(first[0].msg.includes(`(${T6})`), first[0].msg); + assert.deepEqual(era(at(T7, 1), twice), []); + const second = era(at(T9, 1), twice); + assert.equal(second.length, 1); + assert.match(second[0].msg, /в S4-spec-review/); + assert.ok(second[0].msg.includes(`после возврата ${T8}`), second[0].msg); + assert.ok(second[0].msg.includes(`(${T10})`), second[0].msg); + assert.deepEqual(era(at(T10, 1), twice), []); + + // Returned and never ready again. + const stuck = era(at(T4, 1), RETURN_ROUTE.slice(0, 4)); + assert.equal(stuck.length, 1); + assert.equal(stuck[0].level, 'fail'); + assert.ok(stuck[0].msg.includes(`после возврата ${T4}`), stuck[0].msg); + assert.match(stuck[0].msg, /ещё не достигнут/); +}); + +test('#738 AC2: rule 10 keeps the old verdicts on ready-only timelines and ignores non-status labels', () => { + // A timeline of `allowed` labels only gives exactly the old findings. + const readyOnly = RETURN_ROUTE.filter((e) => ALLOWED_STATUS.includes(e.label)); + for (const iso of [at(T1, -1), at(T1, 1), at(T4, 1), at(T6, 1)]) { + const got = era(iso, readyOnly); + if (Date.parse(iso) < Date.parse(T1)) { + assert.deepEqual(got, [{ + level: 'fail', rule: 10, sha: 'aaaaaaaa', + msg: `issue #738: коммит класса A написан ${iso}, до первого достижения задачей статуса из ` + + `${ALLOWED_STATUS.join('/')} (${T1}) — код раньше «Готово к разработке» (§12)`, + }], iso); + } else { + assert.deepEqual(got, [], iso); + } + } + + // Class B and C commits in the S3 era are not rule 10's business. + for (const files of [['scripts/process-gate.mjs'], ['docs/SCOPE.md']]) { + assert.deepEqual(checkCommitEraStatuses( + [eraCode(at(T4, 1), { files })], eraTimeline(RETURN_ROUTE)), [], files[0]); + } + + // `blocked` and `track:ask` between S6 and S7 change nothing. + const noisy = [ + ...RETURN_ROUTE.slice(0, 2), + { label: 'blocked', at: at(T2, 2) }, + { label: 'track:ask', at: at(T2, 3) }, + ...RETURN_ROUTE.slice(2), + ]; + assert.deepEqual(era(at(T2, 4), noisy), []); + assert.equal(era(at(T4, 1), noisy).length, 1); + + // With the strict set S8-merged neither opens nor closes the era. + const s8opens = [ + { label: 'S3-spec', at: T1 }, + { label: 'S8-merged', at: T2 }, + { label: 'S5-ready', at: T3 }, + ]; + const strictEarly = era(at(T2, 1), s8opens, { allowed: STRICT_STATUS }); + assert.equal(strictEarly.length, 1); + assert.match(strictEarly[0].msg, /до первого достижения задачей статуса из/); + assert.ok(strictEarly[0].msg.includes(`(${T3})`), strictEarly[0].msg); + assert.deepEqual(era(at(T2, 1), s8opens), [], 'ALLOWED_STATUS: S8-merged opens'); + const s8closes = [ + { label: 'S5-ready', at: T1 }, + { label: 'S8-merged', at: T2 }, + ]; + assert.deepEqual(era(at(T2, 1), s8closes, { allowed: STRICT_STATUS }), []); + + // Unsorted events give the same result. + const shuffled = [RETURN_ROUTE[3], RETURN_ROUTE[5], RETURN_ROUTE[0], RETURN_ROUTE[4], RETURN_ROUTE[2], RETURN_ROUTE[1]]; + for (const iso of [at(T1, -1), at(T2, 1), at(T4, 1), at(T5, 1), at(T6, 1)]) { + assert.deepEqual(era(iso, shuffled), era(iso), iso); + } + + // authorDate equal to the S3-spec event is already in S3 (`at ≤ w`). + const onEdge = era(T4); + assert.equal(onEdge.length, 1); + assert.match(onEdge[0].msg, /в S3-spec/); + // …and equal to the repeated S5 is already ready. + assert.deepEqual(era(T6), []); +});