mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 21:01:21 +00:00
feat(process): the pipeline records model usage in the review document's machine block (#737)
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: `<!-- hp:usage input_tokens=N output_tokens=N cache_creation_input_tokens=N cache_read_input_tokens=N num_turns=N -->` (sums over every model in the last `result` message, `result.usage` when modelUsage is absent) or `<!-- hp:usage-none reason=<code> -->`. 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+18
@@ -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`: `<!-- hp:usage input_tokens=N output_tokens=N
|
||||
cache_creation_input_tokens=N cache_read_input_tokens=N num_turns=N -->` — суммы
|
||||
по всем моделям `modelUsage`, без него — из `usage`. Нет данных —
|
||||
`<!-- hp:usage-none reason=<код> -->`: `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), и в отборе по
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
* Здесь — единственное место, которое собирает и разбирает строку (у
|
||||
* публикации и отчёта копии формата нет):
|
||||
*
|
||||
* <!-- hp:usage input_tokens=N output_tokens=N cache_creation_input_tokens=N cache_read_input_tokens=N num_turns=N -->
|
||||
* <!-- hp:usage-none reason=<код> -->
|
||||
*
|
||||
* Ровно пять ключей в этом порядке, 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(`^<!-- hp:usage ${USAGE_KEYS.map((key) => `${key}=${COUNT}`).join(' ')} -->$`);
|
||||
const NONE_RE = new RegExp(`^<!-- hp:usage-none reason=(${USAGE_REASONS.join('|')}) -->$`);
|
||||
|
||||
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 `<!-- hp:usage-none reason=${usage.reason} -->`;
|
||||
}
|
||||
if (!isRecord(usage) || !USAGE_KEYS.every((key) => isCount(usage[key]))) {
|
||||
throw new TypeError('model-usage: расход не по формату');
|
||||
}
|
||||
return `<!-- hp:usage ${USAGE_KEYS.map((key) => `${key}=${usage[key]}`).join(' ')} -->`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Строгий разбор одной строки: `{ 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`);
|
||||
}
|
||||
+37
-20
@@ -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 = /<!--\s*hp:route\s+(reclassify|owner-question)\b[^>]*-->/;
|
||||
/**
|
||||
* Машинная строка расхода модели в документе ревью — её запишет конвейер
|
||||
* (issue F, К6 #728): `<!-- hp:usage input_tokens=N output_tokens=N … -->`.
|
||||
* Формат предварительный: пока строки нет нигде, отчёт печатает «нет данных».
|
||||
*/
|
||||
export const USAGE_LINE_RE = /<!--\s*hp:usage\s+([^>]*?)\s*-->/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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 = '<!-- hp:usage input_tokens=97209 output_tokens=55524 cache_creation_input_tokens=149047 cache_read_input_tokens=1135731 num_turns=42 -->';
|
||||
|
||||
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<!-- hp:usage input_tokens=1 -->` }] } },
|
||||
...(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 = '<!-- hp:usage input_tokens=10 output_tokens=20 cache_creation_input_tokens=30 cache_read_input_tokens=40 num_turns=7 -->\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) => `<!-- hp:usage-none reason=${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 = '<!-- hp:usage input_tokens=0 output_tokens=0 cache_creation_input_tokens=0 cache_read_input_tokens=0 num_turns=0 -->';
|
||||
const max = '<!-- hp:usage input_tokens=999999999999 output_tokens=1 cache_creation_input_tokens=2 cache_read_input_tokens=3 num_turns=4 -->';
|
||||
for (const line of [DATA, zero, max, ...USAGE_REASONS.map((reason) => `<!-- hp:usage-none reason=${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-символов в причине', `<!-- hp:usage-none reason=${hex40} -->`],
|
||||
['неизвестная причина', '<!-- hp:usage-none reason=later -->'],
|
||||
['причина без пробела', '<!-- hp:usage-none reason=missing-->'],
|
||||
['двойной пробел', 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), '<!-- hp:usage-none reason=invalid -->', label);
|
||||
assert.doesNotMatch(publishedUsageLine(line), /[0-9a-f]{40}/, label);
|
||||
}
|
||||
for (const empty of ['', ' ', '\n', null, undefined]) {
|
||||
assert.equal(publishedUsageLine(empty), '<!-- hp:usage-none reason=missing -->', JSON.stringify(empty));
|
||||
}
|
||||
});
|
||||
|
||||
test('#737 AC2: строку данных читает предварительный разбор #728, hp:usage-none — нет', () => {
|
||||
// Разбор #728 вписан буквально: откат читателя не сломает чтение данных и не
|
||||
// превратит «нет данных» в документ с пустой суммой.
|
||||
const usageRe = /<!--\s*hp:usage\s+([^>]*?)\s*-->/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 = '<!-- hp:usage-none reason=missing -->';
|
||||
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<!-- hp:usage input_tokens=1 -->\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], ['', '<!-- hp:usage-none reason=no-execution-file -->']]) {
|
||||
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}: секрета нет`);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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: '<!-- hp:usage input_tokens=1200 output_tokens=300 -->' }]);
|
||||
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) => {
|
||||
|
||||
@@ -232,6 +232,28 @@ test('#723 release-review.yml на настоящем bash: dev ушёл впе
|
||||
assert.match(git(box.origin, 'show', `dev:${RELEASE_DOC}`), /<!-- hp-release-review-anchors -->\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), '<!-- hp:usage-none reason=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 = '<!-- hp:usage input_tokens=97209 output_tokens=55524 cache_creation_input_tokens=149047 cache_read_input_tokens=1135731 num_turns=42 -->';
|
||||
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: '' }, '<!-- hp:usage-none reason=missing -->'],
|
||||
['выхода нет вовсе', {}, '<!-- hp:usage-none reason=missing -->'],
|
||||
['мусор с 40 hex', { USAGE: `<!-- hp:usage input_tokens=${'f'.repeat(40)} -->` }, '<!-- hp:usage-none reason=invalid -->'],
|
||||
]) {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1099,3 +1099,58 @@ test('r1 #695: guard даёт инфраструктуре без треково
|
||||
assert.equal(r.out.labels, input.labels.join(','), `${why}: метки для prepare`);
|
||||
}
|
||||
});
|
||||
|
||||
// #737: расход сессии модели — последней строкой блока якорей. Строка приходит
|
||||
// выходом недоверенной стадии и разбирается строго; прежние строки блока и их
|
||||
// разбор не меняются.
|
||||
const USAGE_737 = '<!-- hp:usage input_tokens=97209 output_tokens=55524 cache_creation_input_tokens=149047 cache_read_input_tokens=1135731 num_turns=42 -->';
|
||||
|
||||
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: '' })), '<!-- hp:usage-none reason=missing -->');
|
||||
assert.equal(last(materialAnchorBlock({ ...base, usage: '<!-- hp:usage-none reason=no-result -->' })), '<!-- hp:usage-none reason=no-result -->');
|
||||
const hex = 'e'.repeat(40);
|
||||
for (const junk of [`<!-- hp:usage input_tokens=${hex} -->`, 'x -->\n- Дерево материала: `' + hex + '`', USAGE_737.replace('=42', '=42.5')]) {
|
||||
const block = materialAnchorBlock({ ...base, usage: junk });
|
||||
assert.equal(last(block), '<!-- hp:usage-none reason=invalid -->', 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='), '<!-- hp:usage-none reason=missing -->');
|
||||
assert.equal(anchor('--usage=garbage'), '<!-- hp:usage-none reason=invalid -->');
|
||||
assert.equal(anchor(), '- Вердикт конвейера: `green` · High 0');
|
||||
});
|
||||
|
||||
@@ -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 = '<!-- hp:usage input_tokens=97209 output_tokens=55524 cache_creation_input_tokens=149047 cache_read_input_tokens=1135731 num_turns=42 -->';
|
||||
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}<!-- hp:usage-none reason=missing -->\n`, 'пусто — missing');
|
||||
assert.equal(anchorBlock({ ...fields, usage: `x ${sha('f')} -->` }), `${plain}<!-- hp:usage-none reason=invalid -->\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: '<!-- hp:usage-none reason=no-result -->' }))).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),
|
||||
|
||||
Reference in New Issue
Block a user