From f5a6b47b39202144bafb3e71d29e1c450199cb2d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 10:22:05 +0300 Subject: [PATCH] feat(process): the pipeline records model usage in the review document's machine block (#737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The weekly process metrics weigh tracks and the nightly ship review in the order quality, speed, tokens (#707), but the third axis had no source: the claude-code-action step hides usage from the Actions log on purpose, nothing read its execution_file, and the #728 reader printed "no data" every week. scripts/model-usage.mjs is the single module that builds and parses the line: `` (sums over every model in the last `result` message, `result.usage` when modelUsage is absent) or ``. Only the result message is read; the rest of the file holds tool results, and no byte of it is printed. A new step right after Review in both model_review jobs (always(), continue-on-error) hands the line out as the job output `usage`. Usage is a reporting figure like the stage duration, so it travels as a job output and not through the sealed artifact: REQUIRED_FILES and the #556 gate are unchanged. Publication treats the line as untrusted input and writes the normalized form as the last line of the anchor block (review-doc-guard --anchor --usage=) or right after the SHIP-REVIEW block; empty becomes reason=missing, anything off-format reason=invalid. The #728 reader now takes the line only from the machine block: a reviewer quoting the previous round in prose no longer doubles its usage, and "no data" is counted as missing, never as zero. PROCESS.md §10.4 documents the source, the format and why it is a job output. Issue: #737 User-Visible: no Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- .github/workflows/_process.yml | 23 +++ .github/workflows/_ship-review.yml | 19 ++ PROCESS.md | 18 ++ scripts/model-usage.mjs | 176 +++++++++++++++++ scripts/process-metrics.mjs | 57 ++++-- scripts/review-doc-guard.mjs | 11 +- scripts/ship-review.mjs | 13 ++ test/model-usage.test.mjs | 306 +++++++++++++++++++++++++++++ test/process-metrics.test.mjs | 55 +++++- test/publish-push-refusal.test.mjs | 72 ++++++- test/review-doc-guard.test.mjs | 55 ++++++ test/ship-review.test.mjs | 31 +++ 12 files changed, 808 insertions(+), 28 deletions(-) create mode 100644 scripts/model-usage.mjs create mode 100644 test/model-usage.test.mjs 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),