diff --git a/.github/workflows/_process.yml b/.github/workflows/_process.yml index c81328de..2f2f023d 100644 --- a/.github/workflows/_process.yml +++ b/.github/workflows/_process.yml @@ -1013,6 +1013,8 @@ jobs: timeout-minutes: 45 outputs: duration_seconds: ${{ steps.duration.outputs.seconds }} + # #737: строка расхода сессии модели (scripts/model-usage.mjs). + usage: ${{ steps.usage.outputs.line }} steps: - name: Начать измерение стадии id: clock @@ -1331,6 +1333,23 @@ jobs: --allowedTools Read,Write,Grep,Glob,Bash,mcp__github__add_issue_comment,mcp__github__issue_write,mcp__github__issue_read --json-schema '{"type":"object","properties":{"verdict":{"type":"string","enum":["green","yellow","red"]},"high":{"type":"integer"},"medium":{"type":"integer"},"summary":{"type":"string"},"route":{"type":"string","enum":["fix","reclassify"]},"criterion":{"type":"string"}},"required":["verdict","high","medium","summary","route"]}' + # #737: расход сессии — отчётная величина, как длительность стадии: по + # нему ничего не решается, поэтому он идёт выходом job, а не через + # запечатанный artifact (#556). Публикация разбирает строку строго. Сам + # execution_file — все сообщения сессии, с результатами инструментов — + # не выгружается и не печатается: скрипт берёт из него только `result`. + # `always()` — расход упавшей сессии хотя бы виден в сводке. + - name: Снять расход модели + id: usage + if: always() + continue-on-error: true + env: + EXEC: ${{ steps.review.outputs.execution_file }} + run: | + line=$(node scripts/model-usage.mjs --execution-file="$EXEC") + echo "line=$line" >> "$GITHUB_OUTPUT" + echo "- расход модели: \`$line\`" >> "$GITHUB_STEP_SUMMARY" + - name: Запечатать результат модели id: result env: @@ -1499,6 +1518,9 @@ jobs: # Вердикт из structured_output попадает в блок якорей (#499): по нему # следующий заход решает, можно ли применить зелёный вердикт повторно. OUT: ${{ steps.result.outputs.structured_output }} + # #737: расход модели — последней строкой блока якорей; строка + # недоверенная, скрипт разбирает её строго. + USAGE: ${{ needs.model_review.outputs.usage }} run: | verdict=$(printf '%s' "$OUT" | jq -r '.verdict // empty' 2>/dev/null || true) high=$(printf '%s' "$OUT" | jq -r '.high // empty' 2>/dev/null || true) @@ -1561,6 +1583,7 @@ jobs: --sha="$MATERIAL_SHA" --tree="$MATERIAL_TREE" \ --branch="${BRANCH:-dev}" --specs="$MATERIAL_SPECS" \ --issue-body="$MATERIAL_ISSUE_BODY" \ + --usage="$USAGE" \ --verdict="$verdict" --high="$high" \ --route="$route" --criterion="$criterion" else diff --git a/.github/workflows/_ship-review.yml b/.github/workflows/_ship-review.yml index 9acc8cf8..17789b38 100644 --- a/.github/workflows/_ship-review.yml +++ b/.github/workflows/_ship-review.yml @@ -125,6 +125,9 @@ jobs: if: needs.prepare.outputs.proceed == 'true' runs-on: ubuntu-24.04 timeout-minutes: 45 + # #737: строка расхода сессии модели (scripts/model-usage.mjs). + outputs: + usage: ${{ steps.usage.outputs.line }} # Недоверенная стадия без права записи (#556): документ публикует # `publish`. `github_token` у шага Review обязателен — без него action # меняет OIDC на собственный App-токен с правом записи. @@ -241,6 +244,19 @@ jobs: --allowedTools Read,Write,Grep,Glob,Bash --json-schema '{"type":"object","properties":{"high":{"type":"integer"},"medium":{"type":"integer"},"low":{"type":"integer"},"summary":{"type":"string"}},"required":["high","medium","low","summary"]}' + # #737: как у конвейера (_process.yml) — расход выходом job, не через + # artifact; execution_file не выгружается и не печатается. + - name: Снять расход модели + id: usage + if: always() + continue-on-error: true + env: + EXEC: ${{ steps.review.outputs.execution_file }} + run: | + line=$(node scripts/model-usage.mjs --execution-file="$EXEC") + echo "line=$line" >> "$GITHUB_OUTPUT" + echo "- расход модели: \`$line\`" >> "$GITHUB_STEP_SUMMARY" + - name: Запечатать результат модели env: SOURCE: ${{ runner.temp }}/ship-review.md @@ -301,6 +317,8 @@ jobs: MODE: ${{ needs.prepare.outputs.mode }} PATCHES: ${{ needs.prepare.outputs.patches }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + # #737: расход модели — строкой сразу после блока; недоверенный ввод. + USAGE: ${{ needs.model_review.outputs.usage }} run: | dir="$RUNNER_TEMP/ship-review-result" (cd "$dir" && sha256sum -c manifest.sha256) @@ -325,6 +343,7 @@ jobs: issues: String(process.env.ISSUES || "").split(",").filter(Boolean).map(Number), high: Number(process.argv[1]), medium: Number(process.argv[2]), low: Number(process.argv[3]), runUrl: process.env.RUN_URL, mode: process.env.MODE, patches: m.parsePatches(process.env.PATCHES), + usage: process.env.USAGE ?? "", }))); ' "$high" "$medium" "$low" } > "$DOC" diff --git a/PROCESS.md b/PROCESS.md index 7ff54d9a..14d56b91 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1428,6 +1428,24 @@ merge. Timeout/cancel/failure называет конкретную стадию если модель не запускалась, цикл ревью не расходуется. Длительности всех трёх стадий печатаются отдельной таблицей в summary прогона. +**Расход модели** (#737). Шаг `Review` отдаёт `execution_file` — все сообщения +сессии SDK, а журнал Actions расход намеренно прячет. Шаг «Снять расход модели» +сразу после `Review` (`if: always()`, `continue-on-error`) берёт из файла только +последнее сообщение `result` (`scripts/model-usage.mjs`) и отдаёт одну строку +выходом job `usage`: `` — суммы +по всем моделям `modelUsage`, без него — из `usage`. Нет данных — +``: `no-execution-file`, `unreadable`, +`no-result`, `no-usage`, у публикации ещё `missing` и `invalid`. Расход — +отчётная величина, как длительность стадии: по нему ничего не решается, поэтому +он идёт выходом job, а не запечатанным artifact, и `REQUIRED_FILES` остаётся +«ровно эти файлы». Сам файл не выгружается и не печатается: в нём результаты +инструментов. Публикация разбирает строку как недоверенный ввод и пишет её +последней строкой блока якорей (`review-doc-guard.mjs --anchor --usage=`), а в +`SHIP-REVIEW` — сразу после машинного блока. «Метрики процесса» читают строку +только из машинного блока: цитата в прозе не считается, «нет данных» — не ноль. +Расход упавшей сессии виден только в сводке прогона. + Каждый раунд ревью платит только за то, что в нём изменилось (#518). Свидетель судится по **области своего якоря** — строкам патча плюс сорок строк с каждой стороны (`ANCHOR_RADIUS_LINES`): и в отпечатке журнала (#481), и в отборе по diff --git a/scripts/model-usage.mjs b/scripts/model-usage.mjs new file mode 100644 index 00000000..b14c2548 --- /dev/null +++ b/scripts/model-usage.mjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node +/** + * Расход модели одной машинной строкой (#737, PROCESS.md §10.4). + * + * node scripts/model-usage.mjs --execution-file=<путь> + * + * Шаг `Review` (`claude-code-action`) отдаёт выход `execution_file` — JSON-массив + * всех сообщений сессии SDK, он пишется и при ошибке SDK. Журнал Actions + * расхода не покажет: action сознательно печатает результат урезанным + * («without exposing token usage or cost details»). Других следов расхода у + * конвейера нет, а без них у отчёта «Метрики процесса» нет третьей оси + * «качество → скорость → токены» (#707, #728). + * + * Здесь — единственное место, которое собирает и разбирает строку (у + * публикации и отчёта копии формата нет): + * + * + * + * + * Ровно пять ключей в этом порядке, N — десятичное целое без знака и без + * ведущих нулей, не длиннее 12 цифр. Формат замораживается первой публикацией: + * документы ревью задним числом не правят. Поэтому ключи — имена SDK без + * перевода, а «нет данных» — отдельный маркер, а не отсутствие строки: иначе + * «конвейер не смог» не отличить от «документа до #737». Предварительный + * читатель #728 строку данных читает, а `hp:usage-none` — нет: после + * `hp:usage` ему нужен пробел. В строке нет SHA и хешей: блок якорей кода + * считает якорем каждые 40 hex-символов (`materialAnchorsFrom`). + * + * Из файла берётся только последнее сообщение `type: "result"`. Остальное в + * нём — результаты инструментов, то есть возможные секреты, и ни одна их + * буква не печатается: даже сообщение об ошибке JSON.parse цитирует текст, + * поэтому причины здесь — коды, а не тексты ошибок. Скрипт не падает на + * данных: любой исход — строка, код выхода 0. + */ +import { readFileSync } from 'node:fs'; +import { isMainModule } from './spawn-portable.mjs'; + +/** Ключи строки данных — в порядке строки. */ +export const USAGE_KEYS = Object.freeze([ + 'input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'num_turns', +]); + +/** Токены — поля `modelUsage[<модель>]` SDK (camelCase) и `result.usage` (snake_case). */ +const MODEL_USAGE_FIELDS = Object.freeze({ + input_tokens: 'inputTokens', + output_tokens: 'outputTokens', + cache_creation_input_tokens: 'cacheCreationInputTokens', + cache_read_input_tokens: 'cacheReadInputTokens', +}); + +/** + * Причины «нет данных». Первые четыре — снятие из `execution_file`, две + * последние — публикация: строки от стадии модели нет (`missing`) или она + * пришла не по формату (`invalid`). + */ +export const USAGE_REASONS = Object.freeze([ + 'no-execution-file', 'unreadable', 'no-result', 'no-usage', 'missing', 'invalid', +]); + +/** Наибольшее значение, которое помещается в 12 цифр. */ +const MAX_COUNT = 999_999_999_999; +const COUNT = '(0|[1-9]\\d{0,11})'; +const DATA_RE = new RegExp(`^$`); +const NONE_RE = new RegExp(`^$`); + +const isCount = (value) => Number.isSafeInteger(value) && value >= 0 && value <= MAX_COUNT; +const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value); + +/** + * Строка из разобранного вида: `{ input_tokens, …, num_turns }` либо + * `{ reason }`. Обратна `parseUsageLine`. Вход не по контракту — исключение: + * это ошибка вызывающего кода, не данных. + */ +export function formatUsage(usage) { + if (isRecord(usage) && 'reason' in usage) { + if (!USAGE_REASONS.includes(usage.reason)) throw new TypeError(`model-usage: неизвестная причина ${JSON.stringify(usage.reason)}`); + return ``; + } + if (!isRecord(usage) || !USAGE_KEYS.every((key) => isCount(usage[key]))) { + throw new TypeError('model-usage: расход не по формату'); + } + return ``; +} + +/** + * Строгий разбор одной строки: `{ input_tokens, …, num_turns }`, + * `{ reason }` или `null` (не по формату). Пробелы по краям, лишний или + * пропущенный ключ, другой порядок, знак, дробь, ведущий ноль, 13 цифр, + * перевод строки внутри — `null`. + */ +export function parseUsageLine(line) { + const text = String(line ?? ''); + const data = DATA_RE.exec(text); + if (data) return Object.fromEntries(USAGE_KEYS.map((key, index) => [key, Number(data[index + 1])])); + const none = NONE_RE.exec(text); + return none ? { reason: none[1] } : null; +} + +/** + * Строка для документа ревью из недоверенного выхода стадии модели (#556): + * пусто — `missing`, не по формату — `invalid`, иначе та же строка. + */ +export function publishedUsageLine(raw) { + const text = String(raw ?? '').trim(); + if (!text) return formatUsage({ reason: 'missing' }); + return formatUsage(parseUsageLine(text) ?? { reason: 'invalid' }); +} + +/** Последняя строка формата в тексте машинного блока, разобранная, либо `null`. */ +export function lastUsageIn(text) { + let found = null; + for (const line of String(text ?? '').split('\n')) { + const parsed = parseUsageLine(line.trim()); + if (parsed) found = parsed; + } + return found; +} + +/** + * Расход из сообщений сессии SDK: сумма по всем моделям `modelUsage` + * последнего `result` (лимит подписки тратят и вспомогательные модели); без + * `modelUsage` — те же ключи из `result.usage`. Значение не целое ≥ 0 — это + * не данные (`unreadable`), а не ноль. + */ +export function usageFromMessages(messages) { + if (!Array.isArray(messages)) return { reason: 'unreadable' }; + const result = messages.findLast((message) => isRecord(message) && message.type === 'result'); + if (!result) return { reason: 'no-result' }; + const models = isRecord(result.modelUsage) ? Object.values(result.modelUsage) : []; + const usage = {}; + if (models.length) { + for (const [key, field] of Object.entries(MODEL_USAGE_FIELDS)) { + usage[key] = 0; + for (const model of models) { + const value = isRecord(model) ? model[field] : undefined; + if (!isCount(value)) return { reason: 'unreadable' }; + usage[key] += value; + } + } + } else if (isRecord(result.usage)) { + for (const key of Object.keys(MODEL_USAGE_FIELDS)) usage[key] = result.usage[key]; + } else { + return { reason: 'no-usage' }; + } + usage.num_turns = result.num_turns; + return USAGE_KEYS.every((key) => isCount(usage[key])) ? usage : { reason: 'unreadable' }; +} + +/** Расход из файла `execution_file`; файла нет или путь пуст — `no-execution-file`. */ +export function usageFromExecutionFile(path, read = (file) => readFileSync(file, 'utf8')) { + if (!String(path ?? '').trim()) return { reason: 'no-execution-file' }; + let text; + try { + text = read(path); + } catch (error) { + return { reason: error?.code === 'ENOENT' ? 'no-execution-file' : 'unreadable' }; + } + let messages; + try { + messages = JSON.parse(text); + } catch { + return { reason: 'unreadable' }; + } + return usageFromMessages(messages); +} + +if (isMainModule(import.meta.url)) { + const flag = process.argv.slice(2).find((item) => item.startsWith('--execution-file=')); + let line; + try { + line = formatUsage(usageFromExecutionFile(flag ? flag.slice('--execution-file='.length) : '')); + } catch { + line = formatUsage({ reason: 'unreadable' }); + } + process.stdout.write(`${line}\n`); +} diff --git a/scripts/process-metrics.mjs b/scripts/process-metrics.mjs index c04d6ecc..da169da8 100644 --- a/scripts/process-metrics.mjs +++ b/scripts/process-metrics.mjs @@ -28,8 +28,9 @@ import { isMainModule } from './spawn-portable.mjs'; import { classify } from './change-classes.mjs'; import { labelTrack, parseNumstat } from './process-track.mjs'; import { issueTrailers } from './release-membership.mjs'; -import { verdictDeclaration } from './review-doc-guard.mjs'; -import { parseAnchorBlock } from './ship-review.mjs'; +import { USAGE_KEYS, lastUsageIn } from './model-usage.mjs'; +import { ANCHOR_MARKER, verdictDeclaration } from './review-doc-guard.mjs'; +import { SHIP_REVIEW_ANCHOR, parseAnchorBlock } from './ship-review.mjs'; import { PIPELINE_EVENTS } from './wait-verdict.mjs'; export const STATUS_LABELS = ['S1-new', 'S2-analysis', 'S3-spec', 'S4-spec-review', 'S5-ready', 'S6-in-progress', 'S7-code-review', 'S8-merged']; @@ -195,7 +196,7 @@ export const RETURN_REASONS = ['verdict-yellow', 'verdict-red', 'validate-red', export const PIPELINE_STAGES = ['guard', 'prepare', 'model', 'integrate']; export const VOLUME_BUCKETS = ['≤30', '31–200', '201–1000', '>1000']; export const VALIDATE_WORKFLOW = 'Проверка (CI)'; -export const TOKENS_NO_DATA = 'Токены: нет данных (конвейер не записывает расход модели)'; +export const TOKENS_NO_DATA = 'Токены: нет данных (ни один документ ревью не несёт расход модели)'; /** * Причины `validate-red` и `conflict` (К3). У конвейера нет для них отдельной @@ -209,12 +210,6 @@ export const NOT_RUN_VALIDATE_RE = /^\*\*Ревью не запускалось: export const NOT_RUN_CONFLICT_RE = /^\*\*Ревью не запускалось:\*\* ветка \S+ не ребейзится на /m; /** Маршрут вердикта show (#726): машинная строка комментария конвейера. */ export const ROUTE_RE = //; -/** - * Машинная строка расхода модели в документе ревью — её запишет конвейер - * (issue F, К6 #728): ``. - * Формат предварительный: пока строки нет нигде, отчёт печатает «нет данных». - */ -export const USAGE_LINE_RE = //g; /** Признак конвейера по `kind`: переименование в `wait-verdict.mjs` ломает загрузку, а не молча даёт `unknown`. */ function pipelineEvent(kind) { @@ -444,19 +439,37 @@ export function shipFindings(reviewDocs = []) { return { byIssue, docs }; } -/** К6. Расход модели по машинным строкам документов ревью; ни одной — `null` («нет данных»). */ +/** + * #737: строка расхода модели документа ревью — только из машинного блока, + * который пишет конвейер: после `ANCHOR_MARKER` (ревью ТЗ и кода) или после + * последнего `SHIP_REVIEW_ANCHOR` (`SHIP-REVIEW-*`). Проза выше маркера не + * источник: ревьюер r2 цитирует документ r1, и расход r1 считался бы дважды. + * Формат и разбор — `model-usage.mjs`; строки нет — `null`. + */ +export function reviewDocUsage(doc) { + const text = String(doc?.text ?? ''); + const at = SHIP_DOC.test(String(doc?.path || '')) ? text.lastIndexOf(SHIP_REVIEW_ANCHOR) : text.indexOf(ANCHOR_MARKER); + return at < 0 ? null : lastUsageIn(text.slice(at)); +} + +/** + * К6 (#728, #737). Расход модели по документам ревью: `docs` — документы с + * данными, `totals` — суммы по ключам строки (`null`, пока данных нет), + * `missing` — документы с `hp:usage-none`. «Нет данных» — не ноль: в суммы + * не входит. Документы без строки (до #737) не считаются ни тем, ни другим. + */ export function tokenUsage(reviewDocs = []) { - const totals = {}; + const totals = Object.fromEntries(USAGE_KEYS.map((key) => [key, 0])); let docs = 0; + let missing = 0; for (const doc of reviewDocs || []) { - let found = false; - for (const match of String(doc?.text ?? '').matchAll(USAGE_LINE_RE)) { - found = true; - for (const [, key, value] of match[1].matchAll(/([a-z_]+)=(\d+)/g)) totals[key] = (totals[key] || 0) + Number(value); - } - if (found) docs += 1; + const usage = reviewDocUsage(doc); + if (!usage) continue; + if ('reason' in usage) { missing += 1; continue; } + docs += 1; + for (const key of USAGE_KEYS) totals[key] += usage[key]; } - return docs ? { docs, totals } : null; + return { docs, totals: docs ? totals : null, missing }; } /** `git log --format=%x1e%H%x1f%cI%x1f%B%x1f --numstat` → коммиты с трейлерами и строками. */ @@ -953,9 +966,13 @@ function renderTokens(lines, tokens) { lines.push(''); lines.push('### Токены'); lines.push(''); - lines.push(tokens - ? `Токены по ${tokens.docs} документам ревью: ${Object.entries(tokens.totals).map(([key, value]) => `${key} ${value}`).join(' · ')}.` + lines.push(tokens?.docs + ? `Токены по ${tokens.docs} документам ревью: ${USAGE_KEYS.map((key) => `${key} ${tokens.totals[key]}`).join(' · ')}.` : `${TOKENS_NO_DATA}.`); + if (tokens?.missing) { + lines.push(''); + lines.push(`Без данных о расходе: ${tokens.missing}.`); + } } function renderCompare(lines, c) { diff --git a/scripts/review-doc-guard.mjs b/scripts/review-doc-guard.mjs index 74ffa954..b2410abe 100644 --- a/scripts/review-doc-guard.mjs +++ b/scripts/review-doc-guard.mjs @@ -26,6 +26,7 @@ import { createHash } from 'node:crypto'; import { spawnSync } from 'node:child_process'; import { readFileSync, writeFileSync } from 'node:fs'; +import { publishedUsageLine } from './model-usage.mjs'; import { isMainModule } from './spawn-portable.mjs'; export const REVIEW_DOC_ALLOWLIST = ['docs/reviews/']; @@ -312,7 +313,7 @@ const ANCHOR_ROUTES = ['fix', 'reclassify']; /** Идентификатор критерия в якоре: только формат, смысл судит `reviewRoute`. */ const ANCHOR_CRITERION = /^[a-z][a-z0-9-]{0,39}$/; -export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, high, issueBody, route, criterion } = {}) { +export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, high, issueBody, route, criterion, usage } = {}) { const short = (value) => (typeof value === 'string' ? value.slice(0, 12) : ''); const lines = [ ANCHOR_MARKER, @@ -360,6 +361,12 @@ export function materialAnchorBlock({ sha, tree, branch, specs = [], verdict, hi } lines.push(line); } + // #737: расход сессии модели — последней строкой блока (`model-usage.mjs`). + // Значение приходит выходом недоверенной стадии (#556) и разбирается строго: + // пусто — `reason=missing`, не по формату — `reason=invalid`. Прежние строки + // блока не меняются, их разбор тоже. Не передано вовсе (вызов до #737) — + // строки нет. + if (usage != null) lines.push(publishedUsageLine(usage)); return `${lines.join('\n')}\n`; } @@ -806,6 +813,8 @@ if (invokedDirectly) { // #726: маршрут и критерий вердикта; вне словаря и формата — не пишутся. route: value('route'), criterion: value('criterion'), + // #737: строка расхода модели; без флага (вызов до #737) строки нет. + usage: argv.some((item) => item.startsWith('--usage=')) ? value('usage') : undefined, }; const text = readFileSync(path, 'utf8'); writeFileSync(path, withMaterialAnchors(text, anchors), 'utf8'); diff --git a/scripts/ship-review.mjs b/scripts/ship-review.mjs index 992f8067..0c8d22d1 100644 --- a/scripts/ship-review.mjs +++ b/scripts/ship-review.mjs @@ -33,6 +33,7 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { spawnSync } from 'node:child_process'; import { isMainModule } from './spawn-portable.mjs'; +import { lastUsageIn, publishedUsageLine } from './model-usage.mjs'; import { issueTrailers, readCandidateHistory } from './release-membership.mjs'; import { parseDocName } from './reviews-index.mjs'; @@ -341,9 +342,16 @@ export function renderShipBrief({ tag, candidate, base, ship, runUrl = '', doc = /** * Машинный блок документа: его пишет публикация, читает `check`. #727: строки * `mode` и `patches` дописываются в конец, прежние строки не меняются. + * + * #737: `usage` — строка расхода модели (`model-usage.mjs`) сразу после + * закрывающего ``` блока, а не строкой внутри: формат один на все документы + * ревью, а содержимое блока, которое читают гейт беты и покрытие, не меняется. + * Значение — выход недоверенной стадии: пусто — `reason=missing`, не по + * формату — `reason=invalid`. Не передано (вызов до #737) — строки нет. */ export function anchorBlock({ tag, candidate, base = null, issues = [], high = 0, medium = 0, low = 0, runUrl = '', mode = null, patches = null, + usage = null, }) { return [ SHIP_REVIEW_ANCHOR, @@ -361,6 +369,7 @@ export function anchorBlock({ ...(mode ? [`mode ${mode}`] : []), ...(patches ? [`patches ${formatPatches(patches)}`] : []), '```', + ...(usage != null ? [publishedUsageLine(usage)] : []), '', ].join('\n'); } @@ -368,6 +377,8 @@ export function anchorBlock({ /** * Поля машинного блока. `base`, `mode` и `patches` появляются, только если * блок их несёт: документ до #727 без `patches` покрывает задачи по номеру. + * #737: `usage` — разобранная строка расхода после последнего маркера блока, + * если она есть (`{ input_tokens, …, num_turns }` либо `{ reason }`). */ export function parseAnchorBlock(text = '') { const at = String(text).lastIndexOf(SHIP_REVIEW_ANCHOR); @@ -380,6 +391,7 @@ export function parseAnchorBlock(text = '') { })); const number = (value) => (/^\d+$/.test(String(value)) ? Number(value) : null); const given = (value) => value != null && value !== '' && value !== '—'; + const usage = lastUsageIn(String(text).slice(at)); return { tag: fields.tag || null, candidate: fields.candidate || null, @@ -390,6 +402,7 @@ export function parseAnchorBlock(text = '') { ...(given(fields.base) ? { base: fields.base } : {}), ...(given(fields.mode) ? { mode: fields.mode } : {}), ...('patches' in fields ? { patches: parsePatches(fields.patches) } : {}), + ...(usage ? { usage } : {}), }; } diff --git a/test/model-usage.test.mjs b/test/model-usage.test.mjs new file mode 100644 index 00000000..0a88210f --- /dev/null +++ b/test/model-usage.test.mjs @@ -0,0 +1,306 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + USAGE_KEYS, USAGE_REASONS, formatUsage, lastUsageIn, parseUsageLine, publishedUsageLine, + usageFromExecutionFile, usageFromMessages, +} from '../scripts/model-usage.mjs'; +import { REQUIRED_FILES } from '../scripts/review-result-gate.mjs'; + +// #737: расход сессии модели — одной машинной строкой в документе ревью. +// Шаг `Review` (claude-code-action) отдаёт `execution_file` — все сообщения +// сессии SDK, включая результаты инструментов. Из них берётся только последнее +// `result`; остальное — возможные секреты, и ни одна их буква не выходит наружу. + +const ROOT = fileURLToPath(new URL('..', import.meta.url)); +const CLI = join(ROOT, 'scripts', 'model-usage.mjs'); +const TOKEN = 'ghs_' + 'Z9y8X7w6V5u4T3s2R1q0P9o8N7m6L5k4J3i2'; +const DATA = ''; + +const tempDir = (t) => { + const dir = mkdtempSync(join(tmpdir(), 'hp-737-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; +}; + +/** Сессия так, как её пишет action: init, ход модели, результат инструмента с секретом, result. */ +const session = (result) => [ + { type: 'system', subtype: 'init', session_id: 'session-123', model: 'claude-opus-5' }, + { type: 'assistant', message: { content: [{ type: 'tool_use', id: 't1', name: 'Bash', input: { command: 'env' } }] } }, + { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 't1', content: `GH_TOKEN=${TOKEN}\n` }] } }, + ...(result ? [result] : []), +]; +// Поля `modelUsage` — как в фикстуре action на пине (base-action/test/run-claude-sdk.test.ts). +const RESULT = { + type: 'result', subtype: 'success', is_error: false, duration_ms: 434, num_turns: 42, total_cost_usd: 1.23, + // `usage` при непустом `modelUsage` не читается: суммы — по моделям. + usage: { input_tokens: 1, output_tokens: 1, cache_creation_input_tokens: 1, cache_read_input_tokens: 1 }, + modelUsage: { + 'claude-opus-5': { + inputTokens: 96209, outputTokens: 55324, cacheReadInputTokens: 1135701, cacheCreationInputTokens: 149043, + webSearchRequests: 0, costUSD: 1.23, contextWindow: 200000, maxOutputTokens: 64000, + }, + 'claude-haiku-4-5': { + inputTokens: 1000, outputTokens: 200, cacheReadInputTokens: 30, cacheCreationInputTokens: 4, + webSearchRequests: 0, costUSD: 0.01, contextWindow: 200000, maxOutputTokens: 8192, + }, + }, +}; +const USAGE_ONLY = { + type: 'result', subtype: 'success', num_turns: 7, + usage: { input_tokens: 10, output_tokens: 20, cache_creation_input_tokens: 30, cache_read_input_tokens: 40, server_tool_use: { web_search_requests: 0 } }, +}; + +function cli(t, content, { raw = false, path = null } = {}) { + let file = path; + if (content !== undefined) { + file = join(tempDir(t), 'claude-execution-output.json'); + writeFileSync(file, raw ? content : JSON.stringify(content, null, 2)); + } + const args = file === null ? [CLI] : [CLI, `--execution-file=${file}`]; + const r = spawnSync(process.execPath, args, { encoding: 'utf8' }); + assert.equal(r.status, 0, `код выхода CLI — 0: ${r.stderr}`); + assert.ok(!`${r.stdout}${r.stderr}`.includes(TOKEN), 'строки токена из файла в выводе нет'); + assert.ok(!`${r.stdout}${r.stderr}`.includes('GH_TOKEN'), 'ничего из результатов инструментов в выводе нет'); + assert.equal(r.stderr, ''); + return r.stdout; +} + +test('#737 AC1: суммы по моделям из последнего result и num_turns; секрет из tool_result в вывод не попадает', (t) => { + assert.equal(cli(t, session(RESULT)), `${DATA}\n`); + assert.deepEqual(usageFromMessages(session(RESULT)), parseUsageLine(DATA)); + // Последний result, а не первый. + const earlier = { ...USAGE_ONLY, num_turns: 1 }; + assert.equal(cli(t, [...session(earlier), RESULT]), `${DATA}\n`); + // Упавшая сессия (max-turns, is_error) расход тоже несёт. + assert.equal(cli(t, session({ ...RESULT, subtype: 'error_max_turns', is_error: true })), `${DATA}\n`); +}); + +test('#737 AC1: без modelUsage (или с пустым) — те же ключи из result.usage', (t) => { + const expected = '\n'; + assert.equal(cli(t, session(USAGE_ONLY)), expected); + assert.equal(cli(t, session({ ...USAGE_ONLY, modelUsage: {} })), expected); +}); + +test('#737 AC1: нет данных — причина вместо чисел, код выхода 0', (t) => { + const none = (reason) => `\n`; + assert.equal(cli(t, session(null)), none('no-result'), 'сообщения result нет'); + assert.equal(cli(t, []), none('no-result')); + assert.equal(cli(t, undefined, { path: join(tempDir(t), 'absent.json') }), none('no-execution-file'), 'файла нет'); + assert.equal(cli(t, undefined, { path: '' }), none('no-execution-file'), 'выход execution_file пуст'); + assert.equal(cli(t, undefined), none('no-execution-file'), 'флага нет'); + assert.equal(cli(t, `GH_TOKEN=${TOKEN} — не JSON {`, { raw: true }), none('unreadable'), 'не JSON — без цитаты текста'); + assert.equal(cli(t, '', { raw: true }), none('unreadable'), 'пустой файл'); + assert.equal(cli(t, { type: 'result', ...USAGE_ONLY }), none('unreadable'), 'не массив'); + assert.equal(cli(t, undefined, { path: tempDir(t) }), none('unreadable'), 'вместо файла каталог'); + assert.equal(cli(t, session({ type: 'result', subtype: 'success', num_turns: 3 })), none('no-usage')); + const model = RESULT.modelUsage['claude-opus-5']; + for (const [label, result] of [ + ['дробное значение', { ...RESULT, modelUsage: { m: { ...model, inputTokens: 1.5 } } }], + ['отрицательное', { ...RESULT, modelUsage: { m: { ...model, outputTokens: -1 } } }], + ['строка вместо числа', { ...USAGE_ONLY, usage: { ...USAGE_ONLY.usage, cache_read_input_tokens: '40' } }], + ['поле модели пропущено', { ...RESULT, modelUsage: { m: { inputTokens: 1, outputTokens: 1, cacheReadInputTokens: 1 } } }], + ['num_turns нет', { ...USAGE_ONLY, num_turns: undefined }], + ['13 цифр', { ...USAGE_ONLY, num_turns: 1e12 }], + ]) assert.equal(cli(t, session(result)), none('unreadable'), label); +}); + +test('#737 AC1: usageFromExecutionFile не читает путь, которого нет, и терпит любой сбой чтения', () => { + assert.deepEqual(usageFromExecutionFile(''), { reason: 'no-execution-file' }); + assert.deepEqual(usageFromExecutionFile('x', () => { throw Object.assign(new Error('x'), { code: 'ENOENT' }); }), { reason: 'no-execution-file' }); + assert.deepEqual(usageFromExecutionFile('x', () => { throw Object.assign(new Error('x'), { code: 'EACCES' }); }), { reason: 'unreadable' }); + assert.deepEqual(usageFromExecutionFile('x', () => JSON.stringify(session(USAGE_ONLY))).num_turns, 7); + assert.deepEqual(usageFromMessages('[]'), { reason: 'unreadable' }); +}); + +// ---------- К2: формат строки ---------- + +test('#737 AC2: разбор и сборка взаимно обратны; ровно пять ключей и шесть причин', () => { + assert.deepEqual(USAGE_KEYS, ['input_tokens', 'output_tokens', 'cache_creation_input_tokens', 'cache_read_input_tokens', 'num_turns']); + assert.deepEqual(USAGE_REASONS, ['no-execution-file', 'unreadable', 'no-result', 'no-usage', 'missing', 'invalid']); + const zero = ''; + const max = ''; + for (const line of [DATA, zero, max, ...USAGE_REASONS.map((reason) => ``)]) { + const parsed = parseUsageLine(line); + assert.ok(parsed, line); + assert.equal(formatUsage(parsed), line); + assert.deepEqual(parseUsageLine(formatUsage(parsed)), parsed); + assert.equal(publishedUsageLine(line), line, 'публикация пропускает строку по формату как есть'); + assert.equal(publishedUsageLine(` ${line}\n`), line, 'пробелы по краям выхода job не в счёт'); + } + assert.throws(() => formatUsage({ ...parseUsageLine(DATA), num_turns: 1.5 }), TypeError); + assert.throws(() => formatUsage({ reason: 'later' }), TypeError); +}); + +test('#737 AC2: отвергается всё не по формату — публикация пишет invalid, пусто — missing', () => { + const swap = (from, to) => DATA.replace(from, to); + const hex40 = 'a'.repeat(40); + for (const [label, line] of [ + ['лишний ключ', swap(' num_turns=42', ' num_turns=42 total_cost_usd=1')], + ['пропущенный ключ', swap(' num_turns=42', '')], + ['другой порядок', swap('input_tokens=97209 output_tokens=55524', 'output_tokens=55524 input_tokens=97209')], + ['знак +', swap('=97209', '=+97209')], + ['знак −', swap('=97209', '=-97209')], + ['дробь', swap('=97209', '=97209.5')], + ['13 цифр', swap('=97209', '=1234567890123')], + ['ведущий ноль', swap('=97209', '=097209')], + ['--> внутри', swap('=97209', '=97209 -->')], + ['перевод строки внутри', swap(' output_tokens', '\noutput_tokens')], + ['40 hex-символов', swap('=97209', `=${hex40}`)], + ['40 hex-символов в причине', ``], + ['неизвестная причина', ''], + ['причина без пробела', ''], + ['двойной пробел', swap(' output_tokens', ' output_tokens')], + ['без пробела после hp:usage', swap('hp:usage ', 'hp:usage')], + ['ключ в верхнем регистре', swap('input_tokens', 'INPUT_TOKENS')], + ['проза вокруг', `см. ${DATA}`], + ]) { + assert.equal(parseUsageLine(line), null, label); + assert.equal(publishedUsageLine(line), '', label); + assert.doesNotMatch(publishedUsageLine(line), /[0-9a-f]{40}/, label); + } + for (const empty of ['', ' ', '\n', null, undefined]) { + assert.equal(publishedUsageLine(empty), '', JSON.stringify(empty)); + } +}); + +test('#737 AC2: строку данных читает предварительный разбор #728, hp:usage-none — нет', () => { + // Разбор #728 вписан буквально: откат читателя не сломает чтение данных и не + // превратит «нет данных» в документ с пустой суммой. + const usageRe = //g; + const read728 = (text) => { + const totals = {}; + let found = 0; + for (const match of text.matchAll(usageRe)) { + found += 1; + for (const [, key, value] of match[1].matchAll(/([a-z_]+)=(\d+)/g)) totals[key] = (totals[key] || 0) + Number(value); + } + return { found, totals }; + }; + assert.deepEqual(read728(`текст\n${DATA}\n`), { found: 1, totals: parseUsageLine(DATA) }); + for (const reason of USAGE_REASONS) { + assert.deepEqual(read728(`текст\n${formatUsage({ reason })}\n`), { found: 0, totals: {} }, reason); + } +}); + +test('#737 lastUsageIn: последняя строка формата в тексте блока; строка не по формату не в счёт', () => { + const other = ''; + assert.deepEqual(lastUsageIn(`a\n${DATA}\n${other}\n`), { reason: 'missing' }); + assert.deepEqual(lastUsageIn(`a\n${other}\n ${DATA} \nb`), parseUsageLine(DATA)); + assert.equal(lastUsageIn(`a\n\nтекст ${DATA}`), null); + assert.equal(lastUsageIn(''), null); +}); + +// ---------- К3: проводка в обоих workflow ---------- + +const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url)); +const PIPELINES = [ + { file: '_process.yml', publishJob: 'integrate', publishStep: 'Опубликовать документ ревью', consumes: /^ +--usage="\$USAGE" \\$/m }, + { file: '_ship-review.yml', publishJob: 'publish', publishStep: 'Опубликовать документ', consumes: /^ +usage: process\.env\.USAGE \?\? "",$/m }, +]; + +/** Блок job верхнего уровня `jobs:` — до следующего id на двух пробелах. */ +function jobBlock(text, name) { + const start = text.indexOf(`\n ${name}:\n`); + assert.ok(start >= 0, `job ${name}`); + const rest = text.slice(start + 1); + const end = rest.slice(1).search(/\n {2}[a-z_]+:\n/); + return end < 0 ? rest : rest.slice(0, end + 2); +} +/** Шаги job: куски от ` - ` до следующего такого же. */ +const stepsOf = (job) => job.slice(job.indexOf('\n steps:\n')).split(/\n {6}- /).slice(1).map((step) => ` - ${step}`); +/** Тело `run: |` шага, как его прочтёт YAML (блок кончается на строке с отступом меньше десяти). */ +function runOf(step) { + const lines = step.split('\n'); + const from = lines.indexOf(' run: |'); + assert.ok(from > 0, `у шага есть run: |\n${step.slice(0, 200)}`); + const body = []; + for (const line of lines.slice(from + 1)) { + if (line.trim() && !/^ {10}/.test(line)) break; + body.push(line.replace(/^ {10}/, '')); + } + return body.join('\n').replace(/\$\{\{ github\.repository \}\}/g, 'o/r'); +} +const named = (steps, name) => { + const step = steps.find((item) => item.startsWith(` - name: ${name}\n`)); + assert.ok(step, `шаг «${name}»`); + return step; +}; +const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0; + +test('#737 AC5: шаг снятия расхода сразу после Review — always, continue-on-error, выход job usage; публикация его берёт', () => { + for (const { file, publishJob, publishStep, consumes } of PIPELINES) { + const text = readFileSync(join(WORKFLOWS, file), 'utf8'); + const model = jobBlock(text, 'model_review'); + const steps = stepsOf(model); + const review = steps.findIndex((step) => /^ {8}id: review$/m.test(step)); + assert.ok(review >= 0, `${file}: шаг id: review`); + const usage = steps[review + 1]; + assert.ok(usage.startsWith(' - name: Снять расход модели\n'), `${file}: следующий за Review шаг — снятие расхода`); + assert.match(usage, /^ {8}id: usage$/m, file); + assert.match(usage, /^ {8}if: always\(\)$/m, `${file}: расход упавшей сессии тоже в сводке`); + assert.match(usage, /^ {8}continue-on-error: true$/m, `${file}: сбой снятия не роняет стадию`); + assert.match(usage, /^ {10}EXEC: \$\{\{ steps\.review\.outputs\.execution_file \}\}$/m, file); + const run = runOf(usage); + assert.match(run, /^line=\$\(node scripts\/model-usage\.mjs --execution-file="\$EXEC"\)$/m, file); + assert.match(run, /^echo "line=\$line" >> "\$GITHUB_OUTPUT"$/m, file); + assert.match(run, /"\$GITHUB_STEP_SUMMARY"$/m, `${file}: строка — в сводку шага`); + const head = model.slice(0, model.indexOf('\n steps:\n')); + assert.match(head, /^ {4}outputs:\n(?: {6}[^\n]*\n)*? {6}usage: \$\{\{ steps\.usage\.outputs\.line \}\}$/m, `${file}: выход job usage`); + const publish = named(stepsOf(jobBlock(text, publishJob)), publishStep); + assert.match(publish, /^ {10}USAGE: \$\{\{ needs\.model_review\.outputs\.usage \}\}$/m, `${file}: публикация берёт выход job`); + assert.match(runOf(publish), consumes, file); + } + if (!hasBash()) return; + // Изменённые тела проходят bash -n. + for (const { file, publishJob, publishStep } of PIPELINES) { + const text = readFileSync(join(WORKFLOWS, file), 'utf8'); + for (const body of [runOf(named(stepsOf(jobBlock(text, 'model_review')), 'Снять расход модели')), + runOf(named(stepsOf(jobBlock(text, publishJob)), publishStep))]) { + const r = spawnSync('bash', ['-n'], { input: body, encoding: 'utf8' }); + assert.equal(r.status, 0, `${file}: bash -n\n${r.stderr}`); + } + } +}); + +test('#737 AC5: execution_file никуда не выгружается, граница доверия #556 не изменилась', () => { + for (const { file } of PIPELINES) { + const code = readFileSync(join(WORKFLOWS, file), 'utf8').split('\n').filter((line) => !/^\s*#/.test(line)); + assert.ok(!code.some((line) => line.includes('claude-execution-output')), `${file}: файл сессии по имени не упоминается`); + assert.deepEqual(code.filter((line) => line.includes('execution_file')), + [' EXEC: ${{ steps.review.outputs.execution_file }}'], `${file}: execution_file — только вход шага снятия`); + } + assert.deepEqual(REQUIRED_FILES, ['manifest.sha256', 'prepared.json', 'review-document.md', 'verdict.json'], + 'расход не входит в запечатанный artifact'); +}); + +test('#737 AC5: шаг снятия расхода на настоящем bash — выход line и строка в сводке, секрета нет', (t) => { + if (!hasBash()) { t.skip('bash недоступен'); return; } + for (const { file } of PIPELINES) { + const text = readFileSync(join(WORKFLOWS, file), 'utf8'); + const body = runOf(named(stepsOf(jobBlock(text, 'model_review')), 'Снять расход модели')); + const dir = tempDir(t); + const exec = join(dir, 'claude-execution-output.json'); + writeFileSync(exec, JSON.stringify(session(RESULT), null, 2)); + for (const [EXEC, line] of [[exec, DATA], ['', '']]) { + const output = join(dir, 'output'); + const summary = join(dir, 'summary.md'); + rmSync(output, { force: true }); + rmSync(summary, { force: true }); + const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', body], { + cwd: ROOT, encoding: 'utf8', env: { ...process.env, EXEC, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary }, + }); + assert.equal(r.status, 0, `${file}: ${r.stderr}`); + assert.equal(readFileSync(output, 'utf8'), `line=${line}\n`, file); + const printed = readFileSync(summary, 'utf8'); + assert.ok(printed.includes(line), `${file}: строка в сводке`); + assert.equal(printed.split('\n').filter(Boolean).length, 1, `${file}: одна строка сводки`); + for (const where of [r.stdout, r.stderr, printed]) assert.ok(!where.includes(TOKEN), `${file}: секрета нет`); + } + } +}); diff --git a/test/process-metrics.test.mjs b/test/process-metrics.test.mjs index aa6057ff..f93437a9 100644 --- a/test/process-metrics.test.mjs +++ b/test/process-metrics.test.mjs @@ -14,6 +14,8 @@ import { import { labelTrack } from '../scripts/process-track.mjs'; import { commentFor } 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(); @@ -428,14 +430,61 @@ test('#728 контракт: имена job _process.yml дают четыре 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.equal(report.tokens, null); + 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, 'Токены: нет данных (конвейер не записывает расход модели)'); + 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: '' }]); - assert.deepEqual(recorded, { docs: 1, totals: { input_tokens: 1200, output_tokens: 300 } }, 'когда строка появится (issue F), она читается'); + 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) => { diff --git a/test/publish-push-refusal.test.mjs b/test/publish-push-refusal.test.mjs index 1cde6987..828cb83d 100644 --- a/test/publish-push-refusal.test.mjs +++ b/test/publish-push-refusal.test.mjs @@ -232,6 +232,28 @@ test('#723 release-review.yml на настоящем bash: dev ушёл впе assert.match(git(box.origin, 'show', `dev:${RELEASE_DOC}`), /\n### Материал ревью\n\n```\ntag v1\.78\.0\n/); }); +test('#737 AC4 _ship-review.yml на настоящем bash: строка расхода сразу после блока, повтор после сдвига dev — одна; без выхода — missing', async (t) => { + if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; } + const { parseAnchorBlock } = await import('../scripts/ship-review.mjs'); + const { parseUsageLine } = await import('../scripts/model-usage.mjs'); + const box = sandbox(tempRoot(t, 'hp-737-ship-')); + box.neighbour('dev', 1, 'b.mjs', 'export const b = 1;\n'); + const r = runShip(box, { USAGE }); + assert.equal(r.status, 0, r.stderr + r.stdout); + assert.equal(r.pushes, 2, 'документ собран заново после устаревшего lease'); + const doc = git(box.origin, 'show', `dev:${SHIP_DOC}`); + assert.match(doc, new RegExp(`\npatches —\n\`\`\`\n${USAGE}$`), 'строка — сразу после закрывающего ```'); + assert.equal(doc.split('hp:usage').length - 1, 1, 'строка одна'); + const block = parseAnchorBlock(doc); + assert.deepEqual(block.usage, parseUsageLine(USAGE)); + assert.deepEqual([block.high, block.medium, block.low, block.issues], [0, 1, 0, [731, 733]], 'поля блока — прежние'); + const bare = sandbox(tempRoot(t, 'hp-737-ship-')); + assert.equal(runShip(bare).status, 0); + const missing = git(bare.origin, 'show', `dev:${SHIP_DOC}`); + assert.equal(lastLine(missing), ''); + assert.deepEqual(parseAnchorBlock(missing).usage, { reason: 'missing' }); +}); + for (const [label, stderr, kind, reason] of [ ['право на workflow', remoteRejected('dev', WORKFLOW_REASON), PUSH_REFUSAL.workflow, WORKFLOW_REASON], ['прочий [remote rejected] (с заголовком Authorization и чужим токеном)', noisyRejected('dev'), PUSH_REFUSAL.remote, 'protected branch hook declined'], @@ -281,14 +303,14 @@ function taskBranch(box) { git(box.work, 'checkout', '-q', 'dev'); } -function runReviewDoc(box, out = '{"verdict":"green","high":0}') { +function runReviewDoc(box, out = '{"verdict":"green","high":0}', extra = {}) { const source = join(box.temp, 'review-result', 'review-document.md'); mkdirSync(join(box.temp, 'review-result')); writeFileSync(source, '# Код-ревью #9, раунд 1\n\nВердикт: **зелёный** · High: 0 · Medium: 0\n'); return box.run(REVIEW_DOC_STEP(), { BRANCH, NUM: '9', STAGE: 'code', CYCLE: '1', SOURCE: source, MATERIAL_SHA: git(box.origin, 'rev-parse', BRANCH), MATERIAL_TREE: git(box.origin, 'rev-parse', `${BRANCH}^{tree}`), - MATERIAL_SPECS: '', MATERIAL_ISSUE_BODY: '', OUT: out, + MATERIAL_SPECS: '', MATERIAL_ISSUE_BODY: '', OUT: out, ...extra, }); } @@ -365,13 +387,55 @@ test('#726 AC5 _process.yml на настоящем bash: публикация assert.equal(anchor(git(plain.origin, 'show', `${BRANCH}:${REVIEW_DOC}`)), '- Вердикт конвейера: `green` · High 0'); }); +// #737 AC3: расход модели — последней строкой блока якорей. Выход job +// `model_review.usage` недоверенный: пусто — `reason=missing`, мусор — `invalid`. +const USAGE = ''; +const lastLine = (text) => text.trimEnd().split('\n').at(-1); + +test('#737 AC3 _process.yml на настоящем bash: строка расхода — последней в блоке; без выхода модели — missing; мусор — invalid', async (t) => { + if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; } + const { ANCHOR_MARKER, anchorVerdictFrom, materialAnchorsFrom } = await import('../scripts/review-doc-guard.mjs'); + for (const [label, extra, line] of [ + ['с выходом usage', { USAGE }, USAGE], + ['выход пуст', { USAGE: '' }, ''], + ['выхода нет вовсе', {}, ''], + ['мусор с 40 hex', { USAGE: `` }, ''], + ]) { + const box = sandbox(tempRoot(t, 'hp-737-doc-')); + taskBranch(box); + const tree = git(box.origin, 'rev-parse', `${BRANCH}^{tree}`); + const r = runReviewDoc(box, undefined, extra); + assert.equal(r.status, 0, `${label}: ${r.stderr}${r.stdout}`); + const doc = git(box.origin, 'show', `${BRANCH}:${REVIEW_DOC}`); + assert.equal(lastLine(doc), line, label); + assert.equal(doc.split('hp:usage').length - 1, 1, `${label}: строка одна`); + assert.ok(doc.indexOf(ANCHOR_MARKER) < doc.indexOf('hp:usage'), `${label}: строка в блоке якорей`); + assert.deepEqual(anchorVerdictFrom(doc), { verdict: 'green', high: 0 }, label); + assert.deepEqual(materialAnchorsFrom(doc), [tree], `${label}: новых якорей нет`); + } +}); + +test('#737 AC3 _process.yml на настоящем bash: ребейз и второй push — строка расхода одна', (t) => { + if (!hasTools()) { t.skip('bash/tar/jq/sha256sum недоступны'); return; } + const box = sandbox(tempRoot(t, 'hp-737-doc-')); + taskBranch(box); + box.neighbour(BRANCH, 1, 'b.mjs', 'export const b = 1;\n'); + const r = runReviewDoc(box, undefined, { USAGE }); + assert.equal(r.status, 0, r.stderr + r.stdout); + assert.equal(r.pushes, 2); + assert.ok(r.calls.some((call) => /(^| )rebase origin\/issue\/9-fix$/.test(call)), 'ребейз на сдвинутую ветку'); + const doc = git(box.origin, 'show', `${BRANCH}:${REVIEW_DOC}`); + assert.equal(lastLine(doc), USAGE); + assert.equal(doc.split(USAGE).length - 1, 1, 'строка одна'); +}); + // ---------- #730 _ship-review.yml: SHIP-REVIEW в dev ---------- const SHIP_STEP = () => stepRun('_ship-review.yml', 'Опубликовать документ'); const BETA = 'v1.79.0-beta.1'; const SHIP_DOC = `docs/reviews/SHIP-REVIEW-${BETA}.md`; -function runShip(box) { +function runShip(box, extra = {}) { const dir = join(box.temp, 'ship-review-result'); mkdirSync(dir); const files = { @@ -383,7 +447,7 @@ function runShip(box) { .map(([name, text]) => `${createHash('sha256').update(text).digest('hex')} ${name}\n`).join('')); return box.run(SHIP_STEP(), { TAG: BETA, DOC: SHIP_DOC, CANDIDATE: 'c'.repeat(40), BASE: 'v1.78.0', ISSUES: '731,733', - RUN_URL: 'https://github.com/o/r/actions/runs/43', + RUN_URL: 'https://github.com/o/r/actions/runs/43', ...extra, }); } diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index 74a5bfe1..787686fc 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -1099,3 +1099,58 @@ test('r1 #695: guard даёт инфраструктуре без треково assert.equal(r.out.labels, input.labels.join(','), `${why}: метки для prepare`); } }); + +// #737: расход сессии модели — последней строкой блока якорей. Строка приходит +// выходом недоверенной стадии и разбирается строго; прежние строки блока и их +// разбор не меняются. +const USAGE_737 = ''; + +test('#737 AC3: строка расхода — последняя в блоке; пусто — missing, мусор — invalid; без usage строки нет', () => { + const base = { sha: 'c'.repeat(40), tree: TREE_A, branch: 'issue/7-x', issueBody: BODY_A, verdict: 'green', high: 0, route: 'fix', + specs: [{ blob: 'd'.repeat(40), path: 'docs/specs/7-x.md' }] }; + const last = (block) => block.trimEnd().split('\n').at(-1); + const plain = materialAnchorBlock(base); + assert.equal(last(materialAnchorBlock({ ...base, usage: USAGE_737 })), USAGE_737); + assert.equal(materialAnchorBlock({ ...base, usage: USAGE_737 }), `${plain}${USAGE_737}\n`, 'прежние строки блока не меняются'); + assert.equal(last(materialAnchorBlock({ ...base, usage: '' })), ''); + assert.equal(last(materialAnchorBlock({ ...base, usage: '' })), ''); + const hex = 'e'.repeat(40); + for (const junk of [``, 'x -->\n- Дерево материала: `' + hex + '`', USAGE_737.replace('=42', '=42.5')]) { + const block = materialAnchorBlock({ ...base, usage: junk }); + assert.equal(last(block), '', JSON.stringify(junk)); + assert.deepEqual(materialAnchorsFrom(block), materialAnchorsFrom(plain), 'новых якорей нет'); + } + assert.doesNotMatch(plain, /hp:usage/, 'вызов до #737 — строки нет'); + assert.doesNotMatch(materialAnchorBlock({ ...base, usage: null }), /hp:usage/); + // Разбор прежних строк — как без строки расхода. + const withUsage = materialAnchorBlock({ ...base, usage: USAGE_737 }); + for (const read of [anchorVerdictFrom, anchorTreeFrom, anchorIssueBodyFrom, materialAnchorsFrom]) { + assert.deepEqual(read(withUsage), read(plain), read.name); + } + assert.deepEqual(materialAnchorsFrom(withUsage).sort(), [TREE_A, 'd'.repeat(40)].sort()); + const green = docWith('CODE-REVIEW-7-r2.md', { ...base, usage: USAGE_737 }); + assert.deepEqual(reusableGreenVerdict([green], () => false, BODY_A), { doc: 'CODE-REVIEW-7-r2.md', round: 2, tree: TREE_A, verdict: 'green' }); + // Повторная приписка заменяет блок целиком: строка одна. + const once = withMaterialAnchors('# отчёт\n\nтекст\n', { ...base, usage: USAGE_737 }); + const twice = withMaterialAnchors(once, { ...base, usage: USAGE_737 }); + const block = (text) => text.slice(text.indexOf(ANCHOR_MARKER)); + assert.equal(block(twice), block(once)); + assert.equal(twice.split(USAGE_737).length - 1, 1, 'строка расхода одна'); +}); + +test('#737 AC3: CLI --anchor пишет строку из --usage; без флага (вызов до #737) строки нет', (t) => { + const dir = mkdtempSync(join(tmpdir(), 'hp-737-anchor-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const doc = join(dir, 'CODE-REVIEW-7-r1.md'); + const anchor = (...flags) => { + writeFileSync(doc, '# CODE-REVIEW-7-r1\n'); + const r = spawnSync(process.execPath, [fileURLToPath(new URL('../scripts/review-doc-guard.mjs', import.meta.url)), + `--anchor=${doc}`, `--sha=${'c'.repeat(40)}`, `--tree=${TREE_A}`, '--branch=issue/7-x', '--verdict=green', '--high=0', ...flags], { encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + return readFileSync(doc, 'utf8').trimEnd().split('\n').at(-1); + }; + assert.equal(anchor(`--usage=${USAGE_737}`), USAGE_737); + assert.equal(anchor('--usage='), ''); + assert.equal(anchor('--usage=garbage'), ''); + assert.equal(anchor(), '- Вердикт конвейера: `green` · High 0'); +}); diff --git a/test/ship-review.test.mjs b/test/ship-review.test.mjs index f7d7a23e..f48c1612 100644 --- a/test/ship-review.test.mjs +++ b/test/ship-review.test.mjs @@ -394,6 +394,37 @@ test('#727 AC8 К8: строка о High ночью — с меткой доку assert.deepEqual(highCommentTargets({ doc, issues }), [701, 703], 'метка того же документа — повтора нет; другой документ — пишется'); }); +// #737 К5: расход модели — строкой сразу после закрывающего ``` машинного +// блока; содержимое блока, которое читают гейт беты и покрытие, не меняется. +test('#737 AC4: anchorBlock пишет строку расхода после блока, parseAnchorBlock её отдаёт; без usage — как прежде', () => { + const usage = ''; + const fields = { tag: 'nightly', candidate: sha('e'), base: 'v1.79.0-beta.1', issues: [701, 702], high: 1, medium: 2, low: 3, + runUrl: 'https://github.com/o/r/actions/runs/9', mode: 'nightly', patches: new Map([[701, [PID('a')]], [702, [PID('b')]]]) }; + const plain = anchorBlock(fields); + const withUsage = anchorBlock({ ...fields, usage }); + assert.equal(withUsage, `${plain}${usage}\n`, 'строка — сразу после закрывающего ```, блок тот же'); + assert.ok(plain.endsWith('```\n')); + const fenced = (text) => /```\n([\s\S]*?)\n```/.exec(text)[1]; + assert.equal(fenced(withUsage), fenced(plain), 'содержимое между ``` то же'); + assert.equal(anchorBlock({ ...fields, usage: '' }), `${plain}\n`, 'пусто — missing'); + assert.equal(anchorBlock({ ...fields, usage: `x ${sha('f')} -->` }), `${plain}\n`, 'мусор — invalid'); + assert.doesNotMatch(plain, /hp:usage/, 'вызов до #737 — строки нет'); + const doc = (block) => `# Ночное ревью\nИтог: High 1 · Medium 2 · Low 3\n\n${block}`; + const before = parseAnchorBlock(doc(plain)); + assert.equal('usage' in before, false, 'документ без строки — без usage'); + assert.deepEqual(parseAnchorBlock(doc(withUsage)), { + ...before, + usage: { input_tokens: 97209, output_tokens: 55524, cache_creation_input_tokens: 149047, cache_read_input_tokens: 1135731, num_turns: 42 }, + }); + assert.deepEqual(parseAnchorBlock(doc(anchorBlock({ ...fields, usage: '' }))).usage, { reason: 'no-result' }); + // Строка в прозе модели до маркера — не строка блока. + assert.equal('usage' in parseAnchorBlock(`# Ревью\n${usage}\n\n${plain}`), false); + // Покрытие и гейт читают документ со строкой так же, как без неё. + const ship = [{ number: 701, patches: [PID('a')] }, { number: 702, patches: [PID('b')] }]; + const named = (block) => [{ name: NIGHT(), text: doc(block) }]; + assert.deepEqual(shipCoverage({ ship, docs: named(withUsage), base: 'v1.79.0-beta.1' }), shipCoverage({ ship, docs: named(plain), base: 'v1.79.0-beta.1' })); +}); + // ---------- #727: шаги _ship-review.yml на настоящем bash и git ---------- // // Шаги исполняются как есть, из файла workflow: подготовка (prepare),