Files
houseplan-card/test/process-metrics.test.mjs
T
Claude b7dbc21c42 fix(process-metrics): task volume, merge return reasons and spec drafts (#752)
The weekly report disagreed with its own definitions (#728) and with the
pipeline texts that appeared after it (#705, #723, #729).

Volume. A task's volume counted documentation, so the archive move of #682
weighed 333 060 lines and #680/#681 thousands, all landing in the "> 1000"
bucket and shifting the cohort medians; and every commit with a Release:
trailer was dropped, including the task's own golden acceptance and test
re-pinning commits. Volume is now the +/- of class A and B files only.
Only beta commits are left out: the beta or release candidate (Release:
trailer plus the candidate subject or a changed bundle, bundle-policy.mjs,
drops every Release: commit, so it is not reused. A task whose commits
touch only docs/reviews/** has no volume and is no longer read as
infrastructure: the pipeline writes those documents, not the task.

Return reasons. Every non-merge outcome of merge-candidate.mjs fell to
"unknown". merge-candidate.mjs now exports a sign for the heading of each
outcome comment (OUTCOME_SIGNS; the step-failure text moved into
commentFor as 'error', byte for byte), and a test on the templates
themselves holds every case to its own sign. The report maps merge-stage
outcomes after a green verdict to "merge" and a push refused while
rebasing before review to the new "push-refused" reason.

Spec drafts. A new section after "По трекам" counts the S4-spec-review
epochs on the ask track for tasks whose first S5-ready falls in the
window, the epochs with a "Черновик:" comment (§7.2) and whether the draft
went to S5 or was thrown at S3, Spec-Draft: commits in dev for the window,
and S5 -> S7 per track for tasks with and without a draft, "мало данных"
under three. The snapshot also reads timelines of tasks in S5-S7.

Three #728 assertions pinned the old behaviour (a Release: fixture without
the candidate subject, and the rebase workflow refusal read as unknown);
they now expect the new definitions.

Issue: #752
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-10-01 18:21:30 +03:00

894 lines
66 KiB
JavaScript
Raw 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.
// #637: еженедельный замер процесса — чистые функции над снимками GitHub.
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 { dirname, join } from 'node:path';
import {
buildReport, issueMetrics, jobMinutes, pipelineMetrics, renderMarkdown, reviewDocNames, reviewRounds, runMetrics,
BETA_DERIVED_SUBJECT, DRAFT_COMMENT_RE, NOT_RUN_CONFLICT_RE, NOT_RUN_VALIDATE_RE, RETURN_REASONS, TIMELINE_PAGE_CAP, TOKENS_NO_DATA, TRACKS_CUTOVER,
changeVolume, compareCohorts, draftSection, fetchSnapshot, isBetaCommit, isInfra, issueChanges, issueSegments, issueTrackMetrics, jobRuns, jobStage,
readCommits, returnSignal, reviewDocAddedAt, shipFindings, specEpochs, stageMinutes, tokenDocs, tokenUsage, trackAt, trackPath, trackSection, volumeBucket,
} from '../scripts/process-metrics.mjs';
import { labelTrack } from '../scripts/process-track.mjs';
import { commentFor, describePushRefusal } from '../scripts/merge-candidate.mjs';
import { anchorBlock } from '../scripts/ship-review.mjs';
import { withMaterialAnchors } from '../scripts/review-doc-guard.mjs';
import { formatUsage } from '../scripts/model-usage.mjs';
import { PIPELINE_EVENTS } from '../scripts/wait-verdict.mjs';
const T = (h) => new Date(Date.UTC(2026, 8, 15, 0, Math.round(h * 60))).toISOString();
const labeled = (name, h) => ({ event: 'labeled', label: { name }, created_at: T(h) });
test('#637 issueMetrics: вход по первой статусной метке, S7/S8 по первой постановке, повторы считаются отдельно', () => {
const m = issueMetrics({ number: 600, title: 't', closed_at: T(30), state_reason: 'completed' }, [
labeled('bug', 0), labeled('S2-analysis', 1), labeled('S3-spec', 2), labeled('S4-spec-review', 3),
labeled('S5-ready', 5), labeled('S6-in-progress', 6), labeled('S7-code-review', 10),
{ event: 'unlabeled', label: { name: 'S7-code-review' }, created_at: T(11) },
labeled('S7-code-review', 12), labeled('S8-merged', 20),
]);
assert.equal(m.enteredAt, Date.parse(T(1)), 'bug — не статус');
assert.equal(m.leadToS7Ms, 9 * 3_600_000);
assert.equal(m.reviewToMergeMs, 10 * 3_600_000);
assert.equal(m.leadToS8Ms, 19 * 3_600_000);
assert.equal(m.specLeadMs, 2 * 3_600_000);
assert.equal(m.s7Requests, 2);
assert.equal(m.s4Requests, 1);
const bare = issueMetrics({ number: 1, closed_at: 'x', state_reason: 'not_planned' }, []);
assert.equal(bare.leadToS7Ms, null);
assert.equal(bare.closedAt, null);
});
test('#637 reviewRounds: максимум раунда по документам, отдельно CODE и SPEC', () => {
const rounds = reviewRounds(['CODE-REVIEW-600-r1.md', 'CODE-REVIEW-600-r2.md', 'SPEC-REVIEW-600-r1.md', 'CODE-REVIEW-601-r1.md', 'README.md', 'CODE-REVIEW-issue-5.md']);
assert.equal(rounds.get('CODE:600'), 2);
assert.equal(rounds.get('SPEC:600'), 1);
assert.equal(rounds.get('CODE:601'), 1);
assert.equal(rounds.size, 3);
});
const run = (over) => ({
name: 'Проверка (CI)', event: 'push', conclusion: 'success', run_started_at: T(0), updated_at: T(1), ...over,
});
test('#637 runMetrics: по workflow — исходы, wall-time, события; прогоны конвейера сведены в одну строку', () => {
const rows = runMetrics([
run(), run({ conclusion: 'failure', event: 'workflow_dispatch' }), run({ conclusion: 'cancelled', updated_at: T(0) }),
run({ name: 'Ревью-конвейер', display_title: 'process #600 · S7-code-review · x', event: 'issues', updated_at: T(2) }),
run({ name: 'Ревью-конвейер', display_title: 'process #600 · bug · x', event: 'issues', conclusion: 'skipped', updated_at: T(0) }),
]);
const validate = rows.find((r) => r.workflow === 'Проверка (CI)');
assert.deepEqual([validate.runs, validate.success, validate.failure, validate.cancelled], [3, 1, 1, 1]);
assert.equal(validate.wallMs, 2 * 3_600_000, 'отменённый без длительности не считается');
assert.deepEqual(validate.events, { push: 2, workflow_dispatch: 1 });
const process = rows.find((r) => r.workflow === 'process');
assert.deepEqual([process.runs, process.skipped], [2, 1]);
});
test('#637 pipelineMetrics: минуты S7/S4 по имени прогона, skipped не считаются', () => {
const p = pipelineMetrics([
run({ display_title: 'process #600 · S7-code-review · x', updated_at: T(1) }),
run({ display_title: 'process #600 · S7-code-review · x', updated_at: T(2) }),
run({ display_title: 'process #601 · S7-code-review · x', conclusion: 'skipped', updated_at: T(2) }),
run({ display_title: 'process #602 · S4-spec-review · x', run_started_at: T(0), updated_at: T(0.5) }),
]);
assert.deepEqual(p['S7-code-review'], { runs: 2, issues: 1, wallMinutes: 180, perRunMinutes: 90 });
assert.deepEqual(p['S4-spec-review'], { runs: 1, issues: 1, wallMinutes: 30, perRunMinutes: 30 });
});
test('#637 jobMinutes: доля «Мутанты» по именам job', () => {
const jobs = new Map([[1, [
{ name: 'Мутанты по диффу (1/6): x', started_at: T(0), completed_at: T(1) },
{ name: 'Фронтенд: типы', started_at: T(0), completed_at: T(0.5) },
{ name: 'сломан', started_at: 'x', completed_at: T(1) },
]]]);
assert.deepEqual(jobMinutes(jobs), { totalMinutes: 90, mutantMinutes: 60, mutantShare: 67 });
assert.deepEqual(jobMinutes(new Map()), { totalMinutes: 0, mutantMinutes: 0, mutantShare: null });
});
test('#637 buildReport + renderMarkdown: сводка воспроизводит цифры аудита на фикстуре', () => {
// Форма аудита 22.09: 30 issue с код-ревью — 15 r1, 13 r2, 2 r3 → 1.57; Validate 226 прогонов.
const issues = [];
const timelines = new Map();
const reviewFiles = [];
for (let n = 1; n <= 30; n++) {
issues.push({ number: n, title: `t${n}`, closed_at: T(48), state_reason: 'completed' });
timelines.set(n, [labeled('S1-new', 0), labeled('S7-code-review', 24), labeled('S8-merged', 30)]);
const rounds = n <= 15 ? 1 : n <= 28 ? 2 : 3;
for (let r = 1; r <= rounds; r++) reviewFiles.push(`CODE-REVIEW-${n}-r${r}.md`);
}
issues.push({ number: 99, title: 'dropped', closed_at: T(48), state_reason: 'not_planned' });
const runs = Array.from({ length: 226 }, (_, k) => run({ conclusion: k < 178 ? 'success' : k < 215 ? 'failure' : 'cancelled' }));
const report = buildReport({ since: T(0), until: T(48), issues, timelines, reviewFiles, runs });
assert.equal(report.issues.completed, 30);
assert.equal(report.issues.notPlanned, 1);
assert.equal(Math.round(report.issues.codeReviewRounds.mean * 100) / 100, 1.57);
assert.deepEqual(report.issues.codeReviewRounds.distribution, { 1: 15, 2: 13, 3: 2 });
assert.equal(report.issues.medianLeadToS7Hours, 24);
assert.equal(report.issues.medianReviewToMergeHours, 6);
const validate = report.runs.find((r) => r.workflow === 'Проверка (CI)');
assert.deepEqual([validate.runs, validate.success, validate.failure, validate.cancelled], [226, 178, 37, 11]);
const md = renderMarkdown(report);
assert.match(md, /Закрыто issue: \*\*31\*\* \(completed 30, not_planned 1\)/);
assert.match(md, /Раундов код-ревью на issue \| 1\.57 \(r1: 15, r2: 13, r3: 2\)/);
assert.match(md, /\| Проверка \(CI\) \| 226 \| 178 \| 37 \| 11 \| 0 \| 13560 \|/);
assert.match(md, /\| #99 \| not_planned \| — \| — \| — \/ — \| 0 \|/);
assert.equal(report.jobs, null, 'без jobs job-минуты не выдумываются');
});
test('#637 buildReport: нулевая медиана не смешивается с отсутствием данных', () => {
const issue = { number: 637, title: 'fast', closed_at: T(4 / 60), state_reason: 'completed' };
const timelines = new Map([[637, [
labeled('S1-new', 0),
labeled('S4-spec-review', 0),
labeled('S5-ready', 1 / 60),
labeled('S7-code-review', 2 / 60),
labeled('S8-merged', 3 / 60),
]] ]);
const report = buildReport({ since: T(0), until: T(1), issues: [issue], timelines });
assert.deepEqual([
report.issues.medianLeadToS7Hours,
report.issues.medianReviewToMergeHours,
report.issues.medianLeadToS8Hours,
report.issues.medianSpecLeadHours,
], [0, 0, 0.1, 0]);
assert.match(renderMarkdown(report), /Медиана вход → S7 \| 0 ч/);
const empty = buildReport({ since: T(0), until: T(1) });
assert.equal(empty.issues.medianLeadToS7Hours, null);
assert.match(renderMarkdown(empty), /Медиана вход → S7 \| —/);
});
test('#637 workflow: еженедельный запуск читает только, публикует summary и artifact', () => {
const wf = readFileSync(new URL('../.github/workflows/_process-metrics.yml', import.meta.url), 'utf8');
// #623: расписание — у тонкого вызывающего файла, тело — в `_process-metrics.yml`.
const caller = readFileSync(new URL('../.github/workflows/process-metrics.yml', import.meta.url), 'utf8');
assert.match(caller, /schedule:\n(?:\s+#[^\n]*\n)*\s+- cron: '/);
assert.match(caller, /workflow_dispatch:/);
assert.ok(!/issues: write/.test(caller), 'потолок прав вызывающего тоже без записи в issue');
assert.match(wf, /permissions:\n\s+contents: read\n\s+actions: read\n\s+issues: read/);
assert.match(wf, /node scripts\/process-metrics\.mjs[\s\S]*--output=artifacts\/process-metrics\/report\.md/);
assert.match(wf, /GITHUB_STEP_SUMMARY/);
assert.ok(!/issues: write/.test(wf), 'метрики ничего не пишут в issue');
});
test('#682 reviewDocNames: живой каталог и архив legacy/reviews/<тег>/ считаются вместе', () => {
const listing = [
'docs/reviews/CODE-REVIEW-600-r2.md',
'docs/reviews/INDEX.md',
'legacy/reviews/v1.77.0/CODE-REVIEW-600-r1.md',
'legacy/reviews/v1.77.0/SPEC-REVIEW-601-r1.md',
'legacy/docs/ROADMAP.md',
'',
].join('\n');
const names = reviewDocNames(listing);
assert.deepEqual(names, ['CODE-REVIEW-600-r2.md', 'INDEX.md', 'CODE-REVIEW-600-r1.md', 'SPEC-REVIEW-601-r1.md']);
// Перенос r1 в архив не уменьшает число раундов задачи.
assert.equal(reviewRounds(names).get('CODE:600'), 2);
});
// ---------------------------------------------------------------------------
// #728: эффект процесса по трекам.
const unlabeled = (name, h) => ({ event: 'unlabeled', label: { name }, created_at: T(h) });
const commented = (body, h) => ({ event: 'commented', body, created_at: T(h) });
const labeledAt = (name, iso) => ({ event: 'labeled', label: { name }, created_at: iso });
const HOUR = 3_600_000;
test('#728 trackAt: трек на момент события и путь трека, прежние метки и инфраструктура (AC1)', () => {
const events = [
labeled('S1-new', 0), labeled('track:ship', 0), labeled('S5-ready', 1), labeled('S6-in-progress', 2),
unlabeled('track:ship', 5), labeled('track:show', 5), labeled('S7-code-review', 6), labeled('S8-merged', 9),
unlabeled('track:show', 10), labeled('track:ask', 10),
];
assert.equal(trackAt(events, T(4)), 'ship', 'до смены');
assert.equal(trackAt(events, T(5)), 'ship', 'в сам момент смены — метки строго до него');
assert.equal(trackAt(events, T(6)), 'show', 'после смены');
assert.deepEqual(trackPath(events, { from: T(0), to: T(9) }), ['ship', 'show']);
const row = issueTrackMetrics({ number: 701 }, events);
assert.equal(row.track, 'show', 'трек задачи — на момент S8, смена после слияния не в счёт');
assert.deepEqual(row.path, ['ship', 'show']);
// Прежние метки до 28.09 — по §5.1.
const legacy = [labeledAt('S1-new', '2026-09-20T10:00:00Z'), labeledAt('small', '2026-09-20T10:00:00Z')];
assert.equal(trackAt(legacy, '2026-09-21T00:00:00Z'), 'show', 'small → show');
const bare = [labeledAt('S1-new', '2026-09-20T10:00:00Z')];
assert.equal(trackAt(bare, '2026-09-21T00:00:00Z'), 'ask', 'без метки, продукт → ask');
assert.equal(trackAt(bare, '2026-09-21T00:00:00Z', { infra: true }), 'show', 'без метки, инфраструктура → show');
// Две трековые метки — как у process-track.mjs: строжайшая.
const both = [labeled('track:ship', 0), labeled('track:ask', 0)];
assert.equal(trackAt(both, T(1)), labelTrack({ labels: ['track:ship', 'track:ask'] }));
assert.equal(trackAt(both, T(1)), 'ask');
// infra — ни одного файла класса A в коммитах задачи; коммит кандидата беты не в счёт (#752).
const commits = [
{ sha: 'a'.repeat(40), body: 'x\n\nIssue: #801\n', issues: [801], files: [{ added: 5, deleted: 0, path: 'scripts/x.mjs' }] },
{ sha: 'b'.repeat(40), body: 'x\n\nIssue: #802\n', issues: [802], files: [{ added: 5, deleted: 0, path: 'src/x.ts' }] },
{ sha: 'c'.repeat(40), body: 'Release v1.0.0-beta.1 candidate\n\nIssue: #801\nRelease: v1.0.0-beta.1\n', issues: [801], files: [{ added: 1, deleted: 1, path: 'custom_components/houseplan/manifest.json' }] },
];
const timelines = new Map([[801, [labeled('S6-in-progress', 0), labeled('S8-merged', 2)]], [802, [labeled('S6-in-progress', 0), labeled('S8-merged', 2)]]]);
const report = buildReport({ since: T(0), until: T(48), issues: [{ number: 801 }, { number: 802 }], timelines, commits });
const byNumber = new Map(report.tracks.rows.map((r) => [r.number, r]));
assert.equal(byNumber.get(801).track, 'show', 'инфраструктура без метки → show');
assert.equal(byNumber.get(802).track, 'ask', 'продукт без метки → ask');
});
test('#728 issueSegments: отрезки по часам, blocked вычитается, сумма равна lead (AC2)', () => {
const events = [
labeled('S1-new', 0), labeled('S5-ready', 1), labeled('S6-in-progress', 2),
labeled('blocked', 3), unlabeled('blocked', 4),
labeled('S7-code-review', 5), labeled('S6-in-progress', 6), labeled('S7-code-review', 8), labeled('S8-merged', 9),
];
const seg = issueSegments(events);
const inHours = Object.fromEntries(Object.entries(seg.segments).map(([k, v]) => [k, v / HOUR]));
assert.deepEqual(inHours, { queue: 2, spec: 0, work: 2, review: 2, rework: 2, blocked: 1 });
assert.equal(seg.leadMs, 9 * HOUR);
assert.equal(Object.values(seg.segments).reduce((a, b) => a + b, 0), seg.leadMs, 'сумма отрезков = lead');
assert.equal(seg.returns.length, 1);
// Повторная постановка S7 конвейером (#636, #706) — не возврат и не новый отрезок.
const repeated = issueSegments([
...events.slice(0, 6), labeled('S7-code-review', 5.5),
events[6], events[7], unlabeled('S7-code-review', 8.5), labeled('S7-code-review', 8.5), events[8],
]);
assert.deepEqual(repeated.segments, seg.segments);
assert.equal(repeated.returns.length, 1);
// …и не сдвигает начало окна причины: вердикт до повторной постановки остаётся причиной.
const verdictBefore = issueTrackMetrics({ number: 701 }, [
...events.slice(0, 6), commented('Вердикт: жёлтый · Документ: docs/reviews/CODE-REVIEW-701-r1.md', 5.2),
labeled('S7-code-review', 5.5), ...events.slice(6),
]);
assert.deepEqual(verdictBefore.returns.map((r) => r.reason), ['verdict-yellow']);
// Ревью ТЗ: S3 до первого возврата — spec, после S4 → S3 — rework.
const spec = issueSegments([
labeled('S3-spec', 0), labeled('S4-spec-review', 2), labeled('S3-spec', 3), labeled('S4-spec-review', 4),
labeled('S5-ready', 5), labeled('S6-in-progress', 6), labeled('S7-code-review', 7), labeled('S8-merged', 8),
]);
assert.deepEqual(Object.fromEntries(Object.entries(spec.segments).map(([k, v]) => [k, v / HOUR])),
{ queue: 1, spec: 2, work: 1, review: 3, rework: 1, blocked: 0 });
assert.deepEqual(spec.returns.map((r) => r.stage), ['spec']);
assert.equal(issueSegments([labeled('S6-in-progress', 0)]), null, 'без S8 — не в выборке');
});
const NUM = 701;
const VERDICT_YELLOW = `Вердикт: жёлтый · заход r1 · High: 0 · Medium: 2 · Документ: docs/reviews/CODE-REVIEW-${NUM}-r1.md`;
const VERDICT_RED = `**Вердикт: красный** · заход r2 · High: 1 · Документ: docs/reviews/CODE-REVIEW-${NUM}-r2.md`;
/** Шаблоны «Ревью не запускалось» из `_process.yml`: снять экранирование и подставить `$kind`. */
function notRunTemplates() {
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
const templates = workflow.split('\n').map((line) => line.trim()).filter((line) => line.startsWith('**Ревью не запускалось:**'));
const step = workflow.slice(workflow.indexOf('- name: Validate красный — вернуть автору без ревью'));
const kinds = ['Validate', 'Validate с мутантами'];
for (const kind of kinds) assert.ok(step.slice(0, 1500).includes(`kind="${kind}"`), `шаг Validate красный задаёт kind="${kind}"`);
return templates.map((line) => kinds.map((kind) => line.replaceAll('\\`', '`').replaceAll('$kind', kind)
.replaceAll('$BRANCH', 'issue/701-white-tile').replaceAll('$short', '1a2b3c4').replaceAll('$RESULT', 'failed')));
}
test('#728 returnSignal: причины возврата на текстах конвейера (AC3)', () => {
const sig = (body) => returnSignal(body, { stage: 'code', number: NUM });
assert.equal(sig(VERDICT_YELLOW), 'verdict-yellow');
assert.equal(sig(VERDICT_RED), 'verdict-red');
assert.equal(sig(VERDICT_YELLOW.replace(`CODE-REVIEW-${NUM}`, 'CODE-REVIEW-702')), null, 'вердикт о чужом документе — не причина');
assert.equal(sig(`Вердикт: зелёный · заход r1 · Документ: docs/reviews/CODE-REVIEW-${NUM}-r1.md`), null, 'зелёный — не причина возврата');
assert.equal(sig(commentFor('reject-stale', { material: 'a'.repeat(40), actual: 'b'.repeat(40) })), 'merge');
assert.equal(sig(commentFor('conflict', { branch: 'issue/701-x' })), 'merge');
assert.ok(PIPELINE_EVENTS.find((e) => e.kind === 'stale').re.test(commentFor('reject-stale', {})), 'текст под константу конвейера');
assert.equal(sig('**Ревью не запускалось:** что-то третье, о чём отчёт не знает.'), 'unknown');
const refused = commentFor('push-refused-workflow', {
stage: 'rebase', candidate: 'c'.repeat(40), branch: 'issue/701-x', refusal: { files: ['.github/workflows/x.yml'] },
});
assert.match(refused, /^\*\*Ревью не запускалось: кандидат меняет workflow-файл/);
assert.equal(sig(refused), 'push-refused', '#752: отказ push по праву на workflow на ребейзе (#705) — признак merge-candidate.mjs');
assert.equal(sig('Текст.\n\n<!-- hp:route reclassify criterion=undocumented -->'), 'reclassify');
assert.equal(sig('Текст.\n\n<!-- hp:route owner-question criterion=undocumented -->'), 'owner-question');
assert.equal(sig('Пока шло ревью, `dev` продвинулся на 2 коммит(ов).'), null);
assert.equal(NOT_RUN_VALIDATE_RE.flags.includes('m') && NOT_RUN_CONFLICT_RE.flags.includes('m'), true);
});
test('#728 контракт: шаблоны «Ревью не запускалось» _process.yml дают conflict и validate-red (AC3)', () => {
const templates = notRunTemplates();
assert.equal(templates.length, 2, 'шаблонов «Ревью не запускалось:» в _process.yml ровно два');
const reasons = templates.map((variants) => [...new Set(variants.map((text) => returnSignal(text, { stage: 'code', number: NUM })))]);
assert.deepEqual(reasons.map((r) => r.join(',')).sort(), ['conflict', 'validate-red'],
'один шаблон — conflict, другой — validate-red при обоих $kind');
for (const variants of templates) {
for (const text of variants) assert.ok(PIPELINE_EVENTS.find((e) => e.kind === 'conflict').re.test(text), 'общий префикс — под константой PIPELINE_EVENTS');
}
});
test('#728 returnReason: последний комментарий с признаком, возврат без комментария — unknown, трек своего момента (AC3)', () => {
const issue = { number: NUM };
const cycle = (h, comment) => [
labeled('S7-code-review', h), ...(comment ? [commented(comment, h + 0.5)] : []), labeled('S6-in-progress', h + 0.5),
];
const templates = notRunTemplates();
const conflict = templates.find((variants) => variants[0].includes(' ветка '));
const validate = templates.find((variants) => variants !== conflict);
const events = [
labeled('S1-new', 0), labeled('track:ship', 0), labeled('S6-in-progress', 1),
...cycle(2, VERDICT_YELLOW),
...cycle(3, VERDICT_RED),
...cycle(4, validate[0]),
...cycle(5, validate[1]),
unlabeled('track:ship', 5.8), labeled('track:show', 5.8),
...cycle(6, conflict[0]),
...cycle(7, commentFor('reject-stale', {})),
// Комментарий без признака после вердикта не перекрывает причину.
labeled('S7-code-review', 8), commented(VERDICT_YELLOW, 8.2), commented('Пока шло ревью, `dev` продвинулся на 1 коммит(ов).', 8.3), labeled('S6-in-progress', 8.5),
...cycle(9, null),
...cycle(10, VERDICT_YELLOW.replace(`CODE-REVIEW-${NUM}`, 'CODE-REVIEW-702')),
...cycle(11, '**Ревью не запускалось:** иное продолжение.'),
...cycle(12, commentFor('push-refused-workflow', { stage: 'rebase', candidate: 'c'.repeat(40), branch: 'b' })),
labeled('S7-code-review', 13), commented('Ревью show: решать есть что.\n<!-- hp:route owner-question criterion=undocumented -->', 13.5),
labeled('blocked', 13.5), labeled('S6-in-progress', 13.5), unlabeled('blocked', 13.8),
labeled('S7-code-review', 14), commented('Трек повышен.\n<!-- hp:route reclassify criterion=undocumented -->', 14.5),
labeled('S3-spec', 14.5), unlabeled('track:show', 14.5), labeled('track:ask', 14.5),
labeled('S4-spec-review', 15), commented(`Вердикт: красный · Документ: docs/reviews/SPEC-REVIEW-${NUM}-r1.md`, 15.5), labeled('S3-spec', 15.5),
labeled('S4-spec-review', 16), labeled('S5-ready', 16.5), labeled('S6-in-progress', 17), labeled('S7-code-review', 18), labeled('S8-merged', 19),
];
const row = issueTrackMetrics(issue, events);
assert.deepEqual(row.returns.map((r) => r.reason), [
'verdict-yellow', 'verdict-red', 'validate-red', 'validate-red', 'conflict', 'merge', 'verdict-yellow',
'unknown', 'unknown', 'unknown', 'push-refused', 'owner-question', 'reclassify', 'verdict-red',
]);
assert.deepEqual(row.returns.map((r) => r.track), [
'ship', 'ship', 'ship', 'ship', 'show', 'show', 'show', 'show', 'show', 'show', 'show', 'show', 'show', 'ask',
], 'возврат — по треку своего момента; reclassify — на треке, где шло ревью');
assert.deepEqual(row.path, ['ship', 'show', 'ask']);
assert.equal(row.track, 'ask');
const section = trackSection([row]);
const ship = section.events.find((e) => e.track === 'ship');
assert.deepEqual([ship.returns, ship.reasons['verdict-yellow'], ship.reasons['validate-red']], [4, 1, 2]);
assert.deepEqual(['unknown', 'push-refused'].map((reason) => section.events.find((e) => e.track === 'show').reasons[reason]), [3, 1]);
// Раунды — объявления вердикта этапа, по треку и блокирующие/зелёные.
assert.deepEqual([ship.rounds.code.blocking, ship.rounds.code.green], [2, 0]);
assert.deepEqual(section.events.find((e) => e.track === 'ask').rounds.spec, { blocking: 1, green: 0 });
});
test('#728 shipFindings: находки пакетного ревью ship по машинному блоку, архив тоже читается (AC4)', () => {
const doc = (tag, issues, high, medium, low) => anchorBlock({ tag, candidate: 'c'.repeat(40), issues, high, medium, low });
const reviewDocs = [
{ path: 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.2.md', text: `# Пакетное ревью\n\n${doc('v1.79.0-beta.2', [701, 702], 0, 1, 2)}` },
{ path: 'legacy/reviews/v1.78.0/SHIP-REVIEW-v1.78.0-beta.9.md', text: doc('v1.78.0-beta.9', [703], 1, 0, 0) },
{ path: 'docs/reviews/CODE-REVIEW-701-r1.md', text: doc('v1.79.0-beta.2', [701], 9, 9, 9) },
];
const { byIssue } = shipFindings(reviewDocs);
assert.deepEqual(byIssue.get(701), { high: 0, medium: 1, low: 2, docs: ['docs/reviews/SHIP-REVIEW-v1.79.0-beta.2.md'] });
assert.deepEqual([byIssue.get(702).medium, byIssue.get(702).low], [1, 2]);
assert.equal(byIssue.get(703).high, 1, 'документ из legacy/reviews/ читается');
assert.equal(byIssue.has(704), false, 'задачи нет в блоке — находок нет');
const shipped = (n) => [labeled('S1-new', 0), labeled('track:ship', 0), labeled('S6-in-progress', 1), labeled('S7-code-review', 2), labeled('S8-merged', 3)];
const timelines = new Map([701, 702, 704].map((n) => [n, shipped(n)]));
const report = buildReport({ since: T(0), until: T(48), issues: [701, 702, 704].map((number) => ({ number })), timelines, reviewDocs });
const rows = new Map(report.tracks.rows.map((r) => [r.number, r]));
assert.deepEqual([rows.get(701).ship.medium, rows.get(701).ship.low], [1, 2]);
assert.deepEqual(rows.get(704).ship, { high: 0, medium: 0, low: 0, docs: [] });
const ship = report.tracks.byTrack.find((r) => r.track === 'ship').ship;
assert.deepEqual(ship, { issues: 3, covered: 2, docs: 1, high: 0, medium: 1, low: 2 }, 'документ на две задачи — один раз');
assert.match(renderMarkdown(report), /\| ship \| 3 \|[^\n]*покрыто 2 из 3, документов 1: High 0 · Medium 1 · Low 2 \|/);
});
const jobsRun = (id, over) => ({ id, name: 'Ревью-конвейер', event: 'issues', conclusion: 'success', run_started_at: T(10), updated_at: T(11), ...over });
const job = (name, startH, endH) => ({ name, started_at: T(startH), completed_at: T(endH) });
test('#728 stageMinutes: job-минуты по стадиям и треку, Validate по событию, усечение, нет данных (AC5)', () => {
const runs = [
jobsRun(1, { display_title: 'process #701 · S7-code-review · x' }),
jobsRun(2, { display_title: 'process #701 · bug · x', conclusion: 'skipped' }),
jobsRun(3, { name: 'Проверка (CI)', display_title: 'feat: x', event: 'push' }),
jobsRun(4, { name: 'Проверка (CI)', display_title: 'feat: x', event: 'workflow_dispatch' }),
];
const jobsByRun = new Map([
[1, [
job('dev / Страж: ребейз на dev и предпосылки ревью', 10, 10.1),
job('dev / Ревью: материал и deterministic gates', 10.1, 10.3),
job('dev / Ревью: работа модели', 10.3, 10.8),
job('dev / Ревью: публикация и интеграция', 10.8, 10.9),
]],
[3, [job('Фронтенд: типы', 10, 10.25), job('Мутанты по диффу (1/6): x', 10, 10.5)]],
[4, [job('Фронтенд: типы', 10, 11)]],
]);
assert.deepEqual(jobRuns(runs).selected.map((r) => r.id), [1, 3, 4], 'skipped без jobs');
const timelines = new Map([[701, [labeled('S1-new', 0), labeled('track:ship', 0), labeled('S6-in-progress', 1), labeled('S8-merged', 20)]]]);
const report = buildReport({ since: T(0), until: T(48), issues: [{ number: 701 }], timelines, runs, jobsByRun });
assert.deepEqual(report.stages.process.stages, { guard: 6, prepare: 12, model: 30, integrate: 6, other: 0 });
assert.deepEqual(report.stages.process.byTrack, { ship: { guard: 6, prepare: 12, model: 30, integrate: 6, other: 0 } });
assert.deepEqual(report.stages.validate, { runs: 2, minutes: 105, byEvent: { push: 45, workflow_dispatch: 60 } });
assert.equal(report.stages.truncated, null);
const md = renderMarkdown(report);
assert.match(md, /### Job-минуты по стадиям[\s\S]*\| model \| 30 \| 30 \|/);
assert.match(md, /Validate: \*\*105\*\* мин за 2 прогонов \(push 45, workflow_dispatch 60\)/);
assert.match(md, /Job-минуты: \*\*159\*\*, из них «Мутанты» 30/, 'прежняя строка снова печатается');
// 601-й прогон — усечено.
const many = Array.from({ length: 601 }, (_, k) => jobsRun(100 + k, { name: 'Проверка (CI)', display_title: 'x', event: 'push' }));
assert.equal(jobRuns(many).selected.length, 600);
const capped = stageMinutes({ runs: many, jobsByRun: new Map(many.map((r) => [r.id, []])) });
assert.deepEqual(capped.truncated, { fetched: 600, total: 601 });
assert.match(renderMarkdown(buildReport({ since: T(0), until: T(48), runs: many, jobsByRun: new Map() })), /усечено: 600 из 601 прогонов/);
// Jobs недоступны (403, истёк срок) — «нет данных», а не ноль.
const gone = buildReport({ since: T(0), until: T(48), issues: [{ number: 701 }], timelines, runs, jobsByRun: new Map([[1, null], [3, null], [4, null]]) });
assert.equal(gone.stages.process, null);
assert.equal(gone.stages.validate, null);
assert.equal(gone.stages.unavailable, 3);
const goneMd = renderMarkdown(gone);
assert.match(goneMd, /Конвейер: нет данных/);
assert.match(goneMd, /Validate: нет данных/);
assert.doesNotMatch(goneMd.slice(goneMd.indexOf('### Job-минуты по стадиям')), /\| guard \| 0/);
});
test('#728 контракт: имена job _process.yml дают четыре стадии конвейера (AC5)', () => {
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
const names = [...workflow.matchAll(/^ {4}name: "([^"]+)"$/gm)].map((m) => m[1]);
assert.deepEqual(names.map((name) => jobStage(`dev / ${name}`)), ['guard', 'prepare', 'model', 'integrate']);
});
test('#728 tokenUsage: без строки расхода — «нет данных», чисел токенов нет (AC6)', () => {
const report = buildReport({ since: T(0), until: T(48), reviewDocs: [{ path: 'docs/reviews/CODE-REVIEW-701-r1.md', text: '# Ревью\n\nВердикт: зелёный' }] });
assert.deepEqual(report.tokens, { docs: 0, totals: null, missing: 0 });
const md = renderMarkdown(report);
assert.ok(md.includes(TOKENS_NO_DATA));
assert.equal(TOKENS_NO_DATA, 'Токены: нет данных (ни один документ ревью не несёт расход модели)', '#737: конвейер строку пишет');
const section = md.slice(md.indexOf('### Токены'), md.indexOf('###', md.indexOf('### Токены') + 3));
assert.doesNotMatch(section, /\d/, 'ни оценок, ни пересчётов из минут');
// #737: строка вне машинного блока больше не читается — это намеренно.
const recorded = tokenUsage([{ path: 'docs/reviews/CODE-REVIEW-701-r1.md', text: '<!-- hp:usage input_tokens=1200 output_tokens=300 -->' }]);
assert.deepEqual(recorded, { docs: 0, totals: null, missing: 0 }, 'строка без машинного блока — не данные');
});
// #737 К6: расход — только из машинного блока документа (после маркера блока
// якорей или последнего маркера SHIP-REVIEW). «Нет данных» — не ноль.
test('#737 AC6 tokenUsage: только строка машинного блока; суммы по пяти ключам; «нет данных» — не ноль', () => {
const usage = (input, output, creation, read, turns) => formatUsage({
input_tokens: input, output_tokens: output, cache_creation_input_tokens: creation, cache_read_input_tokens: read, num_turns: turns,
});
const r1 = usage(1000, 200, 30, 4000, 5);
const r2 = usage(2000, 300, 40, 5000, 6);
const night = usage(100, 10, 1, 1000, 2);
// r2 цитирует документ r1 в прозе — расход r1 не засчитывается второй раз.
const code = { path: 'docs/reviews/CODE-REVIEW-701-r2.md',
text: withMaterialAnchors(`# CODE-REVIEW-701-r2\n\nУнаследовано из r1:\n\n${r1}\n`, { tree: 'a'.repeat(40), verdict: 'green', high: 0, usage: r2 }) };
const ship = { path: 'docs/reviews/SHIP-REVIEW-v1.0.0-dev-0123456789ab.md',
text: `# Ночное ревью\nИтог: High 0 · Medium 0 · Low 0\n${r1}\n\n${anchorBlock({ tag: 'nightly', candidate: 'c'.repeat(40), issues: [701], usage: night })}` };
assert.deepEqual(tokenUsage([code]), { docs: 1, totals: { input_tokens: 2000, output_tokens: 300, cache_creation_input_tokens: 40, cache_read_input_tokens: 5000, num_turns: 6 }, missing: 0 },
'считается только строка блока, проза до маркера — нет');
const both = tokenUsage([code, ship]);
assert.deepEqual(both, { docs: 2, totals: { input_tokens: 2100, output_tokens: 310, cache_creation_input_tokens: 41, cache_read_input_tokens: 6000, num_turns: 8 }, missing: 0 });
const tokensSection = (report) => {
const md = renderMarkdown(report);
const at = md.indexOf('### Токены');
return md.slice(at, md.indexOf('###', at + 3));
};
const withData = tokensSection(buildReport({ since: T(0), until: T(48), reviewDocs: [code, ship] }));
assert.match(withData, /^Токены по 2 документам ревью: input_tokens 2100 · output_tokens 310 · cache_creation_input_tokens 41 · cache_read_input_tokens 6000 · num_turns 8\.$/m);
assert.doesNotMatch(withData, /Без данных о расходе/);
// hp:usage-none — документ без данных: в суммы не входит, ноль не печатается.
const none = { path: 'legacy/reviews/v1.0/SPEC-REVIEW-702-r1.md',
text: withMaterialAnchors('# SPEC-REVIEW-702-r1\n', { tree: 'b'.repeat(40), verdict: 'green', high: 0, usage: '' }) };
assert.deepEqual(tokenUsage([code, ship, none]), { ...both, missing: 1 });
const mixed = tokensSection(buildReport({ since: T(0), until: T(48), reviewDocs: [code, ship, none] }));
assert.match(mixed, /^Токены по 2 документам ревью: input_tokens 2100 [^\n]*\n\nБез данных о расходе: 1\.$/m);
assert.deepEqual(tokenUsage([none]), { docs: 0, totals: null, missing: 1 });
const onlyNone = tokensSection(buildReport({ since: T(0), until: T(48), reviewDocs: [none] }));
assert.ok(onlyNone.includes(`${TOKENS_NO_DATA}.`));
assert.match(onlyNone, /Без данных о расходе: 1\./);
assert.doesNotMatch(onlyNone, /input_tokens|\b0\b/, '«нет данных» не печатается нулём');
// Строк нет вовсе (документы до #737, строка только в прозе) — «нет данных», цифр нет.
const old = { path: 'docs/reviews/CODE-REVIEW-700-r1.md', text: `# r1\n${r1}\n` };
const empty = tokensSection(buildReport({ since: T(0), until: T(48), reviewDocs: [old] }));
assert.ok(empty.includes(`${TOKENS_NO_DATA}.`));
assert.doesNotMatch(empty, /\d/);
});
test('#728 сравнение: объём из git без Release:, dist/** и docs/reviews/**, корзины и «мало данных» (AC7)', (t) => {
const dir = mkdtempSync(join(tmpdir(), 'hp-728-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const git = (...args) => {
const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
return r.stdout;
};
git('init', '-q', '-b', 'dev');
git('config', 'user.email', 't@t'); git('config', 'user.name', 't');
git('config', 'core.hooksPath', '/dev/null');
const write = (path, lines) => { mkdirSync(join(dir, dirname(path)), { recursive: true }); writeFileSync(join(dir, path), `${Array.from({ length: lines }, (_, k) => `l${k}`).join('\n')}\n`); };
write('src/a.ts', 10); write('dist/houseplan-card.js', 500); write('docs/reviews/CODE-REVIEW-701-r1.md', 40);
git('add', '.'); git('commit', '-q', '-m', 'feat: a (#701)\n\nIssue: #701\nUser-Visible: no');
write('src/b.ts', 300);
git('add', '.'); git('commit', '-q', '-m', 'Release v1.0.0-beta.1 candidate\n\nIssue: #701\nRelease: v1.0.0-beta.1');
write('scripts/x.mjs', 25);
git('add', '.'); git('commit', '-q', '-m', 'test: x (#702)\n\nIssue: #702');
const commits = readCommits((args) => git(...args), { ref: 'HEAD', since: '2000-01-01' });
assert.equal(commits.length, 3);
const changes = issueChanges(commits);
assert.equal(changes.get(701).lines, 10, 'без Release:-коммита, без dist/** и без docs/reviews/**');
assert.equal(changes.get(702).lines, 25);
assert.equal(isInfra(changes.get(701)), false);
assert.equal(isInfra(changes.get(702)), true);
assert.deepEqual([0, 30, 31, 200, 201, 1000, 1001].map(volumeBucket), ['≤30', '≤30', '31–200', '31–200', '201–1000', '201–1000', '>1000']);
assert.equal(volumeBucket(null), null);
const row = (number, iso, track, volume, leadH, returns = 0) => ({
number, s8At: Date.parse(iso), track, volume, bucket: volumeBucket(volume), truncated: false, leadMs: leadH * HOUR,
segments: { queue: 0, spec: 0, work: leadH * HOUR / 2, review: leadH * HOUR / 2, rework: 0, blocked: 0 },
returns: Array.from({ length: returns }, () => ({ reason: 'verdict-yellow' })),
});
const rows = [
row(1, '2026-09-01T00:00:00Z', 'show', 10, 10, 1), row(2, '2026-09-10T00:00:00Z', 'show', 20, 12, 1), row(3, '2026-09-27T23:00:00Z', 'show', 30, 14, 0),
row(4, '2026-09-28T00:00:00Z', 'show', 5, 4), row(5, '2026-09-29T00:00:00Z', 'show', 6, 6), row(6, '2026-10-01T00:00:00Z', 'show', 7, 8),
row(7, '2026-09-05T00:00:00Z', 'ask', 150, 40), row(8, '2026-09-06T00:00:00Z', 'ask', 160, 50),
row(9, '2026-09-30T00:00:00Z', 'ask', 170, 20), row(10, '2026-10-02T00:00:00Z', 'ask', 180, 21), row(11, '2026-10-03T00:00:00Z', 'ask', 190, 22),
row(12, '2026-08-30T23:59:00Z', 'show', 10, 99), row(13, '2026-10-04T00:00:00Z', 'show', 10, 99),
{ ...row(14, '2026-09-20T00:00:00Z', 'show', null, 1), bucket: null },
];
const compare = compareCohorts(rows, { cutover: '2026-09-28', days: 28, until: '2026-10-04T00:00:00Z' });
const show = compare.cohorts.find((c) => c.key === 'show · ≤30');
assert.deepEqual([show.before.n, show.after.n, show.enough], [3, 3, true], '#12 до окна и #13 в until не входят');
assert.deepEqual([show.before.lead, show.after.lead, show.diff.lead], [12, 6, -6]);
assert.deepEqual([show.before.returns, show.after.returns], [0.67, 0]);
const ask = compare.cohorts.find((c) => c.key === 'ask · 31–200');
assert.deepEqual([ask.before.n, ask.after.n, ask.enough, ask.diff], [2, 3, false, null]);
assert.deepEqual(compare.noCommits, [14]);
const md = renderMarkdown({ ...buildReport({ since: T(0), until: T(1) }), compare });
assert.match(md, /\| show · ≤30 \| 3 \| 3 \| 12 → 6 \(-6\) \|/);
assert.match(md, /\| ask · 31–200 \| 2 \| 3 \| мало данных \|/);
assert.match(md, /Без коммитов с трейлером[^\n]*#14/);
});
test('#728 renderMarkdown: новые разделы после прежних, JSON несёт те же поля, buildReport без jobs (AC8)', () => {
const report = buildReport({ since: T(0), until: T(48), issues: [{ number: 701, closed_at: T(30), state_reason: 'completed' }],
timelines: new Map([[701, [labeled('S1-new', 0), labeled('S7-code-review', 2), labeled('S8-merged', 3)]]]) });
assert.equal(report.jobs, null);
assert.equal(report.stages, null, 'jobs передаёт только CLI');
const md = renderMarkdown(report);
const order = ['</details>', '### По трекам', '### Job-минуты по стадиям', '### Токены', `### До и после ${TRACKS_CUTOVER}`].map((s) => md.indexOf(s));
assert.ok(order.every((i) => i > 0) && order.every((i, k) => k === 0 || i > order[k - 1]), `порядок разделов: ${order}`);
assert.match(md, /Нет данных: jobs прогонов не запрашивались/);
const json = JSON.parse(JSON.stringify(report));
for (const key of ['tracks', 'stages', 'tokens', 'compare']) assert.ok(key in json, key);
assert.equal(json.tracks.rows[0].track, 'ask');
});
test('#728 workflow: полная история и потолок 30 минут', () => {
const wf = readFileSync(new URL('../.github/workflows/_process-metrics.yml', import.meta.url), 'utf8');
const metrics = wf.slice(wf.indexOf('\n metrics:\n'));
assert.ok(metrics.length > 1, 'job metrics');
const head = metrics.slice(0, metrics.indexOf('\n steps:'));
assert.match(head, /^ {4}timeout-minutes: 30$/m);
assert.match(metrics, /- uses: actions\/checkout@[0-9a-f]{40}[^\n]*\n {8}with:\n(?: {10}[^\n]*\n)*? {10}fetch-depth: 0\n/);
assert.equal([...wf.matchAll(/fetch-depth:/g)].length, 1, 'другой строки fetch-depth: в файле нет');
});
test('#728 fetchSnapshot: state=all, таймлайн до 10 страниц, jobs недоступны — null', () => {
const calls = [];
const full = Array.from({ length: 100 }, () => labeled('bug', 0));
const gh = (args) => {
const path = args[1];
calls.push(path);
if (path.startsWith('repos/o/r/issues?')) {
return path.includes('page=1') ? [
{ number: 701, state: 'open', labels: [{ name: 'S8-merged' }] },
{ number: 702, state: 'closed', closed_at: T(30), state_reason: 'completed', labels: [] },
{ number: 703, state: 'open', labels: [{ name: 'S6-in-progress' }] },
{ number: 704, pull_request: {}, state: 'open', labels: [] },
] : [];
}
if (path.startsWith('repos/o/r/actions/runs?')) {
return { workflow_runs: [
jobsRun(1, { display_title: 'process #703 · S7-code-review · x' }),
jobsRun(2, { name: 'Проверка (CI)', display_title: 'x', event: 'push' }),
] };
}
if (path === 'repos/o/r/actions/runs/1/jobs?per_page=100&page=1') return { jobs: [job('dev / Ревью: работа модели', 10, 10.5)] };
if (path.startsWith('repos/o/r/actions/runs/2/jobs')) throw new Error('HTTP 410');
if (path.startsWith('repos/o/r/issues/701/timeline')) return full;
if (path.startsWith('repos/o/r/issues/')) return [labeled('S1-new', 0)];
throw new Error(`unexpected ${path}`);
};
const git = (args) => {
if (args[0] === 'ls-tree') return 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.2.md\ndocs/reviews/CODE-REVIEW-701-r1.md\n';
if (args[0] === 'grep') throw new Error('exit 1');
if (args[0] === 'show') return 'ship doc';
if (args[0] === 'rev-parse') return 'f'.repeat(40);
if (args[0] === 'log') return `\x1e${'a'.repeat(40)}\x1f2026-09-30T00:00:00Z\x1fx\n\nIssue: #701\n\x1f\n3\t1\tsrc/a.ts\n`;
throw new Error(`git ${args.join(' ')}`);
};
const snap = fetchSnapshot({ repo: 'o/r', since: T(0), until: T(48), gh, git });
assert.ok(calls[0].includes('state=all') && calls[0].includes(`since=${encodeURIComponent('2026-08-31T00:00:00.000Z')}`), calls[0]);
assert.deepEqual(snap.issues.map((i) => i.number), [702], 'прежняя выборка — закрытые в окне');
assert.deepEqual(snap.allIssues.map((i) => i.number), [701, 702, 703], 'S8, закрытые и задачи прогонов конвейера');
assert.equal(calls.filter((c) => c.startsWith('repos/o/r/issues/701/timeline')).length, TIMELINE_PAGE_CAP);
assert.deepEqual([...snap.timelineTruncated], [701]);
assert.equal(snap.jobsByRun.get(1).length, 1);
assert.equal(snap.jobsByRun.get(2), null);
assert.deepEqual(snap.reviewDocs, [{ path: 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.2.md', text: 'ship doc' }]);
assert.equal(issueChanges(snap.commits).get(701).lines, 4);
});
// ---------------------------------------------------------------------------
// #761: токены — только за окно отчёта, по коммиту, добавившему документ в dev.
test('#761 токены за окно: документ вне окна, перенос в архив в окне и hp:usage-none вне окна не считаются (AC1–AC3)', (t) => {
const dir = mkdtempSync(join(tmpdir(), 'hp-761-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const git = (args, env = {}) => {
const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8', env: { ...process.env, ...env } });
assert.equal(r.status, 0, r.stderr);
return r.stdout;
};
git(['init', '-q', '-b', 'dev']);
git(['config', 'user.email', 't@t']); git(['config', 'user.name', 't']);
git(['config', 'core.hooksPath', '/dev/null']);
const usage = (input, output) => formatUsage({ input_tokens: input, output_tokens: output, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, num_turns: 1 });
const doc = (title, line) => withMaterialAnchors(`# ${title}\n\nВердикт: зелёный.\n`, { tree: 'a'.repeat(40), verdict: 'green', high: 0, usage: line });
const commitAt = (iso, message, files) => {
for (const [path, text] of Object.entries(files)) {
mkdirSync(join(dir, dirname(path)), { recursive: true });
writeFileSync(join(dir, path), text);
}
git(['add', '-A']);
git(['commit', '-q', '-m', message], { GIT_AUTHOR_DATE: iso, GIT_COMMITTER_DATE: iso });
};
const since = '2026-09-24T00:00:00Z';
const until = '2026-10-01T00:00:00Z';
// Вне окна: документ с расходом и документ без данных (hp:usage-none).
commitAt('2026-09-10T10:00:00Z', 'docs: review document for #700', {
'docs/reviews/CODE-REVIEW-700-r1.md': doc('CODE-REVIEW-700-r1', usage(1000, 100)),
'docs/reviews/SPEC-REVIEW-701-r1.md': doc('SPEC-REVIEW-701-r1', ''),
});
// Внутри окна: документ с расходом и документ без данных.
commitAt('2026-09-26T10:00:00Z', 'docs: review document for #702', {
'docs/reviews/CODE-REVIEW-702-r1.md': doc('CODE-REVIEW-702-r1', usage(20, 2)),
'docs/reviews/SPEC-REVIEW-703-r1.md': doc('SPEC-REVIEW-703-r1', ''),
});
// Внутри окна: перенос документов вне окна в архив (#682) — переименование, не добавление.
mkdirSync(join(dir, 'legacy/reviews/v1.0.0'), { recursive: true });
git(['mv', 'docs/reviews/CODE-REVIEW-700-r1.md', 'legacy/reviews/v1.0.0/CODE-REVIEW-700-r1.md']);
git(['mv', 'docs/reviews/SPEC-REVIEW-701-r1.md', 'legacy/reviews/v1.0.0/SPEC-REVIEW-701-r1.md']);
commitAt('2026-09-28T10:00:00Z', 'chore(legacy): archive the v1.0.0 review documents', {});
// После окна: документ с расходом (отчёт за прошлую неделю его не видит).
commitAt('2026-10-02T10:00:00Z', 'docs: review document for #704', { 'docs/reviews/CODE-REVIEW-704-r1.md': doc('CODE-REVIEW-704-r1', usage(5, 5)) });
const added = reviewDocAddedAt(git(['log', '-M', '--diff-filter=AR', '--reverse', '--name-status', '--format=%x1e%cI', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']));
assert.equal(Date.parse(added.get('legacy/reviews/v1.0.0/CODE-REVIEW-700-r1.md')), Date.parse('2026-09-10T10:00:00Z'), 'перенос в архив сохраняет дату добавления');
assert.equal(added.has('docs/reviews/CODE-REVIEW-700-r1.md'), false, 'прежнего пути в HEAD нет');
const gh = (args) => (String(args[1]).includes('/actions/runs?') ? { workflow_runs: [] } : []);
const snap = fetchSnapshot({ repo: 'o/r', since, until, gh, git: (args) => git(args) });
assert.equal(snap.reviewDocs.length, 5, 'тексты читаются из HEAD, как раньше');
const report = buildReport({ since, until, ...snap });
assert.equal(report.tokens.docs, 1, 'AC1: только документ, добавленный в окне');
assert.deepEqual([report.tokens.totals.input_tokens, report.tokens.totals.output_tokens], [20, 2]);
assert.equal(report.tokens.missing, 1, 'AC3: hp:usage-none — по тому же окну');
const md = renderMarkdown(report);
const section = md.slice(md.indexOf('### Токены'), md.indexOf('###', md.indexOf('### Токены') + 3));
assert.match(section, /^Документы ревью, добавленные в `dev` за окно отчёта/m);
assert.match(section, /^Токены по 1 документам ревью: input_tokens 20 · output_tokens 2 /m);
assert.match(section, /^Без данных о расходе: 1\.$/m);
// Неделя, в которую документ #700 был добавлен, его видит — в архиве он или нет.
const early = buildReport({ since: '2026-09-07T00:00:00Z', until: '2026-09-14T00:00:00Z', ...snap });
assert.deepEqual([early.tokens.docs, early.tokens.totals.input_tokens, early.tokens.missing], [1, 1000, 1]);
// Без карты дат (юнит над готовыми документами) окна нет — как до #761.
assert.equal(tokenDocs(snap.reviewDocs).length, 5);
assert.equal(tokenDocs(snap.reviewDocs, { added: new Map(), since, until }).length, 0, 'добавление не найдено — вне окна');
});
// #752: объём задачи, причины возвратов слияния, черновик ТЗ.
/** Временный git-репозиторий: коммиты и `readCommits` по нему. */
function tempRepo(t, prefix) {
const dir = mkdtempSync(join(tmpdir(), prefix));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const git = (...args) => {
const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
return r.stdout;
};
git('init', '-q', '-b', 'dev');
git('config', 'user.email', 't@t'); git('config', 'user.name', 't');
git('config', 'core.hooksPath', '/dev/null');
const write = (path, lines, tag = 'l') => {
mkdirSync(join(dir, dirname(path)), { recursive: true });
writeFileSync(join(dir, path), `${Array.from({ length: lines }, (_, k) => `${tag}${k}`).join('\n')}\n`);
};
const commit = (message) => { git('add', '-A'); git('commit', '-q', '-m', message); };
const commits = () => readCommits((args) => git(...args), { ref: 'HEAD', since: '2000-01-01' });
return { dir, git, write, commit, commits };
}
test('#752 AC1 объём: классы A и B; документация, перенос в архив и документы ревью — нет', (t) => {
const repo = tempRepo(t, 'hp-752-volume-');
repo.write('docs/reviews/R.md', 3);
repo.commit('docs: base');
repo.write('src/a.ts', 10); repo.write('docs/x.md', 5000);
repo.commit('feat: a (#751)\n\nIssue: #751\nUser-Visible: no');
mkdirSync(join(repo.dir, 'legacy/reviews/v1'), { recursive: true });
repo.git('mv', 'docs/reviews/R.md', 'legacy/reviews/v1/R.md');
repo.commit('docs: archive (#751)\n\nIssue: #751\nUser-Visible: no');
repo.write('docs/reviews/SPEC-REVIEW-7-r1.md', 40);
repo.commit('docs: review document for #7\n\nIssue: #7\nUser-Visible: no');
let changes = issueChanges(repo.commits());
assert.equal(changeVolume(changes.get(751)), 10, 'src +10; docs/x.md +5000 и перенос R.md в legacy/reviews — не объём');
assert.equal(volumeBucket(changeVolume(changes.get(751))), '≤30');
assert.equal(isInfra(changes.get(751)), false);
assert.equal(isInfra(changes.get(7)), false, 'только документ ревью — признак инфраструктуры не доказан');
assert.equal(changeVolume(changes.get(7)), null, 'только документ ревью — объёма нет');
repo.write('test/a.test.mjs', 40);
repo.commit('test: a (#751)\n\nIssue: #751\nUser-Visible: no');
changes = issueChanges(repo.commits());
assert.equal(changeVolume(changes.get(751)), 50, 'тесты — класс B, входят');
// Строка задачи без объёма — «без коммитов» сравнения, а не корзина «≤30».
const row = issueTrackMetrics({ number: 7 }, [labeled('S4-spec-review', 0), labeled('S8-merged', 1)], { change: changes.get(7) });
assert.deepEqual([row.infra, row.volume, row.bucket], [false, null, null]);
});
test('#752 AC1 объём: Release:-коммиты задачи входят, кандидат беты, промоушен и бот beta-derived — нет', (t) => {
const repo = tempRepo(t, 'hp-752-release-');
repo.write('src/a.ts', 10);
repo.commit('feat: a (#751)\n\nIssue: #751\nUser-Visible: no');
// Приёмка эталонов задачи: аттестация (класс B) и кадры (класс D).
repo.write('demo/golden/attestation.json', 6); repo.write('demo/golden/baselines/scene.png', 50);
repo.commit('test(golden): accept the reviewed frames (#751)\n\nIssue: #751\nUser-Visible: no\nRelease: v1.0.0-beta.2\nBaseline-Reviewed: https://github.com/o/r/actions/runs/1');
// Перепривязка тестов к бете.
repo.write('test/b.test.mjs', 4);
repo.commit('test(daycycle): witnesses follow the beta (#751)\n\nRelease: v1.0.0-beta.2\nIssue: #751\nUser-Visible: no');
// Кандидат беты: трейлеры всех задач линии, версия и бандл.
repo.write('src/version.ts', 300, 'v'); repo.write('dist/houseplan-card.js', 500);
repo.commit('Release v1.0.0-beta.2 candidate\n\nIssue: #751\nIssue: #752\nUser-Visible: yes\nRelease: v1.0.0-beta.2');
// Кандидат без пересобранного бандла — по подписи.
repo.write('package.json', 7, 'p');
repo.commit('Release v1.0.0-beta.2 candidate refresh\n\nIssue: #751\nRelease: v1.0.0-beta.2');
// Промоушен стабильного — по бандлу.
repo.write('src/version.ts', 300, 'w'); repo.write('dist/houseplan-card.js', 500, 'd');
repo.commit('release: promote v1.0.0 from beta line\n\nIssue: #751\nIssue: #752\nUser-Visible: yes\nRelease: v1.0.0');
// Бот beta-derived (#697): отпечаток скриншотов и эталоны на dev.
repo.write('docs/images/screenshots.json', 12); repo.write('demo/golden/baselines/other.png', 30);
repo.commit('docs: accept derived artifacts on dev for v1.0.0-beta.3\n\nПроизводные артефакты беты.\n\nRelease: v1.0.0-beta.3\nBaseline-Reviewed: https://github.com/o/r/actions/runs/2\nIssue: #697\nUser-Visible: no');
const commits = repo.commits();
assert.deepEqual(commits.filter(isBetaCommit).map((c) => c.body.split('\n')[0]), [
'docs: accept derived artifacts on dev for v1.0.0-beta.3', 'release: promote v1.0.0 from beta line',
'Release v1.0.0-beta.2 candidate refresh', 'Release v1.0.0-beta.2 candidate',
]);
const changes = issueChanges(commits);
assert.equal(changeVolume(changes.get(751)), 20, 'src 10 + аттестация 6 + тест 4; кадры (D) и коммиты беты — нет');
assert.equal(changes.has(752), false, 'задача, названная только кандидатом, объёма не получает');
assert.equal(changes.has(697), false, 'коммит бота — не работа #697');
// Текст в теле, начатый словом «Release:», — не трейлер: коммит задачи остаётся коммитом задачи.
assert.equal(isBetaCommit({ body: 'Release v1.0.0 candidate notes\n\n- dist/x\nRelease: notes wrap here\n\nIssue: #1\n', files: [{ path: 'dist/x.js' }] }), false);
});
test('#752 контракт: подпись коммита бота beta-derived — из _beta-derived.yml', () => {
const workflow = readFileSync(new URL('../.github/workflows/_beta-derived.yml', import.meta.url), 'utf8');
const echo = /echo "(docs: accept derived artifacts on dev for )\$TAG"/.exec(workflow);
assert.ok(echo, 'тема коммита бота в _beta-derived.yml');
assert.match(`${echo[1]}v1.79.0-beta.1`, BETA_DERIVED_SUBJECT);
assert.doesNotMatch('docs: accept the #689 source fingerprint', BETA_DERIVED_SUBJECT);
});
test('#752 AC2 причины: исходы слияния после зелёного вердикта — merge, отказ push на ребейзе — push-refused', () => {
const ctx = { material: 'a'.repeat(40), candidate: 'c'.repeat(40), devNow: 'd'.repeat(40), branch: 'issue/701-x', ref: 'dev', runUrl: 'https://run/1', attempt: 3 };
const remote = (reason) => `To https://github.com/o/r\n ! [remote rejected] 0123abcd -> dev (${reason})\nerror: failed to push some refs`;
const mergeRefused = commentFor('push-refused', { ...ctx, stage: 'merge', refusal: { kind: 'remote-rejected', reason: 'protected branch hook declined', stderr: 'x' } });
const mergeWorkflow = commentFor('push-refused-workflow', { ...ctx, stage: 'merge', refusal: { kind: 'workflow', files: ['.github/workflows/x.yml'] } });
const merge = [
commentFor('validation-red', ctx), commentFor('validation-missing', ctx), commentFor('give-up', ctx),
mergeRefused, mergeWorkflow, commentFor('error', { error: 'git push dev: boom' }),
];
assert.match(mergeRefused, /^\*\*Слияние не выполнено: GitHub отклонил push/);
assert.match(merge.at(-1), /^\*\*Слияние не выполнено: сбой шага слияния/);
const rebaseWorkflow = describePushRefusal(remote('refusing to allow a GitHub App to create or update workflow `.github/workflows/x.yml` without `workflows` permission'),
{ ref: 'issue/701-x', branch: 'issue/701-x', candidate: 'c'.repeat(40), stage: 'rebase' }).comment;
assert.match(rebaseWorkflow, /^\*\*Ревью не запускалось: кандидат меняет workflow-файл/);
const rebaseRemote = describePushRefusal(remote('protected branch hook declined'), { ref: 'issue/701-x', branch: 'issue/701-x', stage: 'rebase' }).comment;
const sig = (body) => returnSignal(body, { stage: 'code', number: NUM });
for (const text of merge) assert.equal(sig(text), 'merge', text.split('\n')[0]);
assert.equal(sig(rebaseWorkflow), 'push-refused');
assert.equal(sig(rebaseRemote), 'push-refused');
assert.equal(sig(commentFor('rereview', ctx)), null, 'дифф изменился — задача идёт в S7, а не автору: причины нет');
assert.ok(RETURN_REASONS.includes('push-refused'));
// В окне: зелёный вердикт, затем исход слияния; возврат без комментария — unknown.
const GREEN = `Вердикт: зелёный · заход r1 · High: 0 · Документ: docs/reviews/CODE-REVIEW-${NUM}-r1.md`;
const cycle = (h, comments) => [
labeled('S7-code-review', h), ...comments.map((body, k) => commented(body, h + 0.1 + k * 0.1)), labeled('S6-in-progress', h + 0.5),
];
const events = [
labeled('S1-new', 0), labeled('track:show', 0), labeled('S6-in-progress', 1),
...merge.flatMap((text, k) => cycle(2 + k, [GREEN, text])),
...cycle(10, [rebaseWorkflow]),
...cycle(11, []),
labeled('S7-code-review', 12), commented(GREEN, 12.1), labeled('S8-merged', 12.5),
];
const row = issueTrackMetrics({ number: NUM }, events);
assert.deepEqual(row.returns.map((r) => r.reason), [...merge.map(() => 'merge'), 'push-refused', 'unknown']);
const show = trackSection([row]).events.find((e) => e.track === 'show');
assert.deepEqual([show.reasons.merge, show.reasons['push-refused'], show.reasons.unknown], [6, 1, 1]);
const md = renderMarkdown(buildReport({ since: T(0), until: T(48), issues: [{ number: NUM }], timelines: new Map([[NUM, events]]) }));
assert.match(md, /\| Трек \| Возвратов \| verdict-yellow \| verdict-red \| validate-red \| conflict \| push-refused \| merge \|/);
assert.match(md, /\| show \| 8 \| 0 \| 0 \| 0 \| 0 \| 1 \| 6 \| 0 \| 0 \| 1 \|/);
});
const DRAFT = 'Черновик: автор · сессия s1 · локальная ветка issue/760-x · Spec-Draft sha256:0123456789ab';
test('#752 AC3 черновик ТЗ: эпохи S4 на ask, черновик выброшен или пошёл в дело, show не в счёт', () => {
const ask = (n, draftEpoch) => [
labeled('S1-new', 0), labeled('track:ask', 0), labeled('S3-spec', 0.5),
labeled('S4-spec-review', 1), ...(draftEpoch === 1 ? [commented(DRAFT, 2)] : []), labeled('S3-spec', 3),
labeled('S4-spec-review', 4), labeled('S4-spec-review', 4.2), ...(draftEpoch === 2 ? [commented(DRAFT, 4.5)] : []), labeled('S5-ready', 5),
labeled('S6-in-progress', 5.5), labeled('S7-code-review', 5 + n),
];
assert.deepEqual(specEpochs(ask(1, 1)).map((e) => [e.close, (e.end - e.start) / HOUR]), [['S3-spec', 2], ['S5-ready', 1]], 'повторная S4 эпоху не начинает');
const thrown = draftSection({ issues: [{ number: 760 }], timelines: new Map([[760, ask(1, 1)]]), since: T(0), until: T(48) });
assert.deepEqual(thrown.epochs, { total: 2, withDraft: 1, used: 0, thrown: 1, other: 0 });
const used = draftSection({ issues: [{ number: 760 }], timelines: new Map([[760, ask(1, 2)]]), since: T(0), until: T(48) });
assert.deepEqual(used.epochs, { total: 2, withDraft: 1, used: 1, thrown: 0, other: 0 });
// show: эпоха S4 не на ask — в счёт не входит.
const show = [labeled('S1-new', 0), labeled('track:show', 0), labeled('S4-spec-review', 1), commented(DRAFT, 1.5), labeled('S5-ready', 2)];
const onlyShow = draftSection({ issues: [{ number: 761 }], timelines: new Map([[761, show]]), since: T(0), until: T(48) });
assert.deepEqual([onlyShow.tasks, onlyShow.epochs.total, onlyShow.epochs.withDraft], [1, 0, 0]);
// Первый S5 вне окна — задачи нет.
assert.equal(draftSection({ issues: [{ number: 760 }], timelines: new Map([[760, ask(1, 2)]]), since: T(6), until: T(48) }).tasks, 0);
// Нет черновиков — строка «черновиков нет», а не ноль в таблице.
const none = renderMarkdown(buildReport({ since: T(0), until: T(48), issues: [{ number: 762 }], timelines: new Map([[762, ask(1, 0)]]) }));
const section = none.slice(none.indexOf('### Черновик ТЗ (#729)'), none.indexOf('### Токены'));
assert.match(section, /Черновиков нет: эпох `S4-spec-review` на ask — 2, комментария «Черновик:» нет ни в одной\./);
assert.doesNotMatch(section, /\| Эпох S4/);
assert.ok(none.indexOf('### По трекам') < none.indexOf('### Черновик ТЗ (#729)'), 'раздел — после «По трекам»');
const drafted = renderMarkdown(buildReport({ since: T(0), until: T(48), issues: [{ number: 760 }], timelines: new Map([[760, ask(1, 1)]]) }));
assert.match(drafted, /\| Эпох S4 \(ask\) \| С черновиком \| Пошёл в дело \(S5\) \| Выброшен \(S3\) \| Открыта или иначе \|\n\|---:\|---:\|---:\|---:\|---:\|\n\| 2 \| 1 \| 0 \| 1 \| 0 \|/);
});
test('#752 AC3 черновик ТЗ: S5 → S7 с черновиком и без по трекам, n < 3 — мало данных; коммиты Spec-Draft за окно', () => {
const ask = (hoursToS7, draft) => [
labeled('S1-new', 0), labeled('track:ask', 0), labeled('S4-spec-review', 1), ...(draft ? [commented(DRAFT, 1.5)] : []),
labeled('S5-ready', 2), labeled('S6-in-progress', 2.1), labeled('S7-code-review', 2 + hoursToS7),
];
const ship = (hoursToS7) => [labeled('S1-new', 0), labeled('track:ship', 0), labeled('S5-ready', 2), labeled('S7-code-review', 2 + hoursToS7)];
// Черновик выброшен в первой эпохе, эпоху, закрытую S5, вёл не черновик — «без черновика».
const thrownThenPlain = [
labeled('S1-new', 0), labeled('track:ask', 0), labeled('S4-spec-review', 0.2), commented(DRAFT, 0.3), labeled('S3-spec', 0.5),
labeled('S4-spec-review', 1), labeled('S5-ready', 2), labeled('S7-code-review', 32),
];
const timelines = new Map([
[1, ask(1, true)], [2, ask(2, true)], [3, ask(3, true)], [4, ask(10, false)], [5, thrownThenPlain],
[6, ship(4)], [7, ship(5)], [8, ship(6)], [9, ask(100, true)],
]);
const commits = [
{ sha: 'a'.repeat(40), date: T(3), body: 'feat: x (#1)\n\nIssue: #1\nSpec-Draft: sha256:' + 'f'.repeat(64) + '\n', issues: [1], files: [] },
{ sha: 'b'.repeat(40), date: T(60), body: 'feat: y (#2)\n\nIssue: #2\nSpec-Draft: sha256:' + 'e'.repeat(64) + '\n', issues: [2], files: [] },
{ sha: 'c'.repeat(40), date: T(3), body: 'feat: z (#3)\n\nIssue: #3\n', issues: [3], files: [] },
];
const d = draftSection({ issues: [...timelines.keys()].map((number) => ({ number })), timelines, commits, since: T(0), until: T(48) });
const byTrack = Object.fromEntries(d.s5ToS7.map((row) => [row.track, row]));
assert.deepEqual(byTrack.ask.draft, { n: 3, hours: 2, enough: true });
assert.deepEqual(byTrack.ask.plain, { n: 2, hours: 20, enough: false });
assert.deepEqual(byTrack.ship.plain, { n: 3, hours: 5, enough: true });
assert.deepEqual(byTrack.show, { track: 'show', draft: { n: 0, hours: null, enough: false }, plain: { n: 0, hours: null, enough: false } });
assert.deepEqual(d.noS7, [9], 'S7 после конца окна — в S5 → S7 не входит');
assert.deepEqual(d.specDraftCommits, { count: 1, issues: [1] }, 'коммит вне окна и без трейлера — нет');
const md = renderMarkdown(buildReport({ since: T(0), until: T(48), issues: [...timelines.keys()].map((number) => ({ number })), timelines, commits }));
assert.match(md, /\| ask \| 3 \| 2 \| 2 \| мало данных \|/);
assert.match(md, /\| ship \| 0 \| мало данных \| 3 \| 5 \|/);
assert.match(md, /Коммиты с трейлером `Spec-Draft:` в `dev` за окно: 1 \(#1\)\./);
assert.match(md, /Без `S7-code-review` к концу окна \(в S5 → S7 не входят\): #9\./);
});
test('#752 контракт: комментарий «Черновик:» — шаблон PROCESS.md §7.2', () => {
const canon = readFileSync(new URL('../PROCESS.md', import.meta.url), 'utf8');
const template = /- \*\*Черновик\*\* \(§11\.8\): `(Черновик: [^`]+)`/.exec(canon.replace(/\n {2}/g, ' '));
assert.ok(template, 'шаблон «Черновик» в §7.2');
assert.match(template[1], DRAFT_COMMENT_RE);
assert.match(DRAFT, DRAFT_COMMENT_RE);
assert.doesNotMatch('Взял: автор · сессия s1 · ветка issue/760-x', DRAFT_COMMENT_RE);
});