From a22f6f54119d2289060eb2dc321ca34645434272 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 17:07:35 +0300 Subject: [PATCH] fix(process-metrics): count model tokens only for the report window (#761) tokenUsage summed the usage line of every review document in HEAD: the report had no window, and every week repeated the whole history. A document now enters a week's tokens when the commit that added it to dev falls in [since, until]. fetchSnapshot reads the committer date from git log -M --diff-filter=AR over docs/reviews and legacy/reviews: an add sets the date, a rename (the #682 archive move) carries it to the new path instead of adding the document again. The "missing" count of #737 (hp:usage-none) follows the same window. ship findings keep reading every SHIP-REVIEW document; only the token sum is windowed. Without the date map (a unit over ready documents) there is no window, as before. Proof is a temporary git repository with dated commits: a document outside the window, one inside, an hp:usage-none pair on both sides and an archive move inside the window; only the inside documents count. Issue: #761 User-Visible: no Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd --- scripts/process-metrics.mjs | 68 ++++++++++++++++++++++++++++++--- test/process-metrics.test.mjs | 71 ++++++++++++++++++++++++++++++++++- 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/scripts/process-metrics.mjs b/scripts/process-metrics.mjs index da169da8..af11a0f0 100644 --- a/scripts/process-metrics.mjs +++ b/scripts/process-metrics.mjs @@ -19,6 +19,9 @@ // у конвейера есть константа; где её нет (`validate-red`/`conflict`), копию // текста держит контрактный тест на шаблонах `_process.yml`. // +// #761: токены — только за окно отчёта: документ ревью входит в неделю, если +// коммит, добавивший его в `dev`, попал в окно (перенос в архив — не добавление). +// // node scripts/process-metrics.mjs --repo= --days=7 [--until=ISO] [--output=path.md] [--json=path.json] // [--compare=2026-09-28] [--compare-days=28] import { execFileSync } from 'node:child_process'; @@ -457,6 +460,7 @@ export function reviewDocUsage(doc) { * данными, `totals` — суммы по ключам строки (`null`, пока данных нет), * `missing` — документы с `hp:usage-none`. «Нет данных» — не ноль: в суммы * не входит. Документы без строки (до #737) не считаются ни тем, ни другим. + * Окно недели (#761) применяет вызывающий: `tokenDocs`. */ export function tokenUsage(reviewDocs = []) { const totals = Object.fromEntries(USAGE_KEYS.map((key) => [key, 0])); @@ -472,6 +476,45 @@ export function tokenUsage(reviewDocs = []) { return { docs, totals: docs ? totals : null, missing }; } +/** + * #761: дата добавления документа ревью в `dev` по выводу + * `git log -M --diff-filter=AR --reverse --name-status --format=%x1e%cI`. + * Добавление (`A`) ставит дату коммиттера; переименование (`R`, перенос в + * архив `legacy/reviews/` #682) переносит дату на новый путь, а не добавляет + * документ заново. → Map путь в `HEAD` → дата (ISO). + */ +export function reviewDocAddedAt(text = '') { + const added = new Map(); + for (const record of String(text).split('\x1e')) { + const [date = '', ...rows] = record.split('\n'); + for (const row of rows) { + const [status = '', from, to] = row.split('\t'); + if (status === 'A' && from) added.set(from, date.trim()); + else if (/^R\d*$/.test(status) && to && added.has(from)) { + added.set(to, added.get(from)); + added.delete(from); + } + } + } + return added; +} + +/** + * #761: документы для токенов недели — добавленные в `dev` в `[since, until]` + * (`reviewDocAddedAt`). Документ, чьё добавление не найдено, в окно не входит. + * Без карты дат (`null`: снимок без git, юнит над готовыми документами) окна + * нет, как до #761. + */ +export function tokenDocs(reviewDocs = [], { added = null, since, until } = {}) { + if (!added) return reviewDocs || []; + const from = toMs(since); + const to = toMs(until); + return (reviewDocs || []).filter((doc) => { + const moment = at(added.get(String(doc?.path ?? ''))); + return moment >= from && moment <= to; + }); +} + /** `git log --format=%x1e%H%x1f%cI%x1f%B%x1f --numstat` → коммиты с трейлерами и строками. */ export function parseGitLog(text = '') { return String(text).split('\x1e').filter((record) => record.trim()).map((record) => { @@ -759,6 +802,8 @@ export function buildReport({ // #728: задачи для разделов по трекам (по умолчанию — те же `issues`), усечённые // таймлайны, тексты документов ревью, коммиты dev и окно сравнения. allIssues = null, timelineTruncated = new Set(), reviewDocs = [], commits = [], compare = {}, + // #761: дата добавления документов ревью в dev — окно токенов; `null` — окна нет. + reviewDocAdded = null, }) { const perIssue = issues.map((issue) => issueMetrics(issue, timelines.get(Number(issue.number)) || [])); const rounds = reviewRounds(reviewFiles); @@ -788,12 +833,12 @@ export function buildReport({ runs: runMetrics(runs), pipeline: pipelineMetrics(runs), jobs: jobsByRun ? jobMinutes(jobsByRun) : null, - ...trackReport({ since, until, issues: allIssues || issues, timelines, timelineTruncated, reviewDocs, runs, jobsByRun, commits, compare }), + ...trackReport({ since, until, issues: allIssues || issues, timelines, timelineTruncated, reviewDocs, reviewDocAdded, runs, jobsByRun, commits, compare }), }; } /** #728: разделы по трекам, job-минуты по стадиям, токены и сравнение до/после. */ -function trackReport({ since, until, issues, timelines, timelineTruncated, reviewDocs, runs, jobsByRun, commits, compare }) { +function trackReport({ since, until, issues, timelines, timelineTruncated, reviewDocs, reviewDocAdded, runs, jobsByRun, commits, compare }) { const changes = issueChanges(commits); const ship = shipFindings(reviewDocs); const seen = new Set(); @@ -816,7 +861,10 @@ function trackReport({ since, until, issues, timelines, timelineTruncated, revie return { tracks: trackSection(rows.filter((row) => row.s8At >= from && row.s8At <= to), { shipDocs: ship.docs }), stages: jobsByRun ? stageMinutes({ runs, jobsByRun, trackOf }) : null, - tokens: tokenUsage(reviewDocs), + // #761: токены и `missing` — по документам, добавленным в dev за окно; `window` — окно применено. + tokens: reviewDocAdded + ? { ...tokenUsage(tokenDocs(reviewDocs, { added: reviewDocAdded, since, until })), window: { since, until } } + : tokenUsage(reviewDocs), compare: compareCohorts(rows, { cutover: compare.cutover ?? TRACKS_CUTOVER, days: compare.days ?? COMPARE_DAYS, until }), }; } @@ -966,6 +1014,10 @@ function renderTokens(lines, tokens) { lines.push(''); lines.push('### Токены'); lines.push(''); + if (tokens?.window) { + lines.push('Документы ревью, добавленные в `dev` за окно отчёта: дата коммиттера добавившего коммита, перенос в архив `legacy/reviews/` — не добавление.'); + lines.push(''); + } lines.push(tokens?.docs ? `Токены по ${tokens.docs} документам ревью: ${USAGE_KEYS.map((key) => `${key} ${tokens.totals[key]}`).join(' · ')}.` : `${TOKENS_NO_DATA}.`); @@ -1019,7 +1071,8 @@ function ghJson(args) { * иначе задача помечается «таймлайн усечён»; * - jobs (К5) — для `jobRuns`, недоступные jobs — `null`; * - имена документов ревью, тексты `SHIP-REVIEW-*.md` и документов со строкой - * расхода, коммиты `origin/dev` с `--numstat` за окно сравнения. + * расхода, дата добавления каждого документа в dev (#761), коммиты + * `origin/dev` с `--numstat` за окно сравнения. */ export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null, compare = {} }) { const sinceIso = new Date(since).toISOString(); @@ -1082,6 +1135,7 @@ export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null, com } let reviewFiles = []; const reviewDocs = []; + let reviewDocAdded = null; let commits = []; if (git) { const listing = git(['ls-tree', '-r', '--name-only', 'HEAD', '--', 'docs/reviews', 'legacy/reviews']); @@ -1095,12 +1149,16 @@ export function fetchSnapshot({ repo, since, until, gh = ghJson, git = null, com for (const path of new Set([...paths.filter((p) => SHIP_DOC.test(p)), ...usage])) { reviewDocs.push({ path, text: git(['show', `HEAD:${path}`]) }); } + // #761: окно токенов — по коммиту, добавившему документ в dev; с -M перенос + // в архив (#682) — переименование, а не добавление. + reviewDocAdded = reviewDocAddedAt(git(['log', '-M', '--diff-filter=AR', '--reverse', '--name-status', + '--format=%x1e%cI', 'HEAD', '--', 'docs/reviews', 'legacy/reviews'])); let ref = 'origin/dev'; try { git(['rev-parse', '--verify', '-q', ref]); } catch { ref = 'HEAD'; } // Коммиты задачи бывают раньше её S8: запас в одно окно до начала сравнения. commits = readCommits(git, { ref, since: at(earliestIso) - days * DAY_MS }); } - return { issues, allIssues, timelines, timelineTruncated, runs, jobsByRun, reviewFiles, reviewDocs, commits }; + return { issues, allIssues, timelines, timelineTruncated, runs, jobsByRun, reviewFiles, reviewDocs, reviewDocAdded, commits }; } if (isMainModule(import.meta.url)) { diff --git a/test/process-metrics.test.mjs b/test/process-metrics.test.mjs index f93437a9..4e5ac4a0 100644 --- a/test/process-metrics.test.mjs +++ b/test/process-metrics.test.mjs @@ -9,7 +9,7 @@ import { buildReport, issueMetrics, jobMinutes, pipelineMetrics, renderMarkdown, reviewDocNames, reviewRounds, runMetrics, NOT_RUN_CONFLICT_RE, NOT_RUN_VALIDATE_RE, TIMELINE_PAGE_CAP, TOKENS_NO_DATA, TRACKS_CUTOVER, compareCohorts, fetchSnapshot, isInfra, issueChanges, issueSegments, issueTrackMetrics, jobRuns, jobStage, - readCommits, returnSignal, shipFindings, stageMinutes, tokenUsage, trackAt, trackPath, trackSection, volumeBucket, + readCommits, returnSignal, reviewDocAddedAt, shipFindings, stageMinutes, tokenDocs, tokenUsage, trackAt, trackPath, trackSection, volumeBucket, } from '../scripts/process-metrics.mjs'; import { labelTrack } from '../scripts/process-track.mjs'; import { commentFor } from '../scripts/merge-candidate.mjs'; @@ -612,3 +612,72 @@ test('#728 fetchSnapshot: state=all, таймлайн до 10 страниц, jo assert.deepEqual(snap.reviewDocs, [{ path: 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.2.md', text: 'ship doc' }]); assert.equal(issueChanges(snap.commits).get(701).lines, 4); }); + +// --------------------------------------------------------------------------- +// #761: токены — только за окно отчёта, по коммиту, добавившему документ в dev. + +test('#761 токены за окно: документ вне окна, перенос в архив в окне и hp:usage-none вне окна не считаются (AC1–AC3)', (t) => { + const dir = mkdtempSync(join(tmpdir(), 'hp-761-')); + t.after(() => rmSync(dir, { recursive: true, force: true })); + const git = (args, env = {}) => { + const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8', env: { ...process.env, ...env } }); + assert.equal(r.status, 0, r.stderr); + return r.stdout; + }; + git(['init', '-q', '-b', 'dev']); + git(['config', 'user.email', 't@t']); git(['config', 'user.name', 't']); + git(['config', 'core.hooksPath', '/dev/null']); + const usage = (input, output) => formatUsage({ input_tokens: input, output_tokens: output, cache_creation_input_tokens: 0, cache_read_input_tokens: 0, num_turns: 1 }); + const doc = (title, line) => withMaterialAnchors(`# ${title}\n\nВердикт: зелёный.\n`, { tree: 'a'.repeat(40), verdict: 'green', high: 0, usage: line }); + const commitAt = (iso, message, files) => { + for (const [path, text] of Object.entries(files)) { + mkdirSync(join(dir, dirname(path)), { recursive: true }); + writeFileSync(join(dir, path), text); + } + git(['add', '-A']); + git(['commit', '-q', '-m', message], { GIT_AUTHOR_DATE: iso, GIT_COMMITTER_DATE: iso }); + }; + const since = '2026-09-24T00:00:00Z'; + const until = '2026-10-01T00:00:00Z'; + // Вне окна: документ с расходом и документ без данных (hp:usage-none). + commitAt('2026-09-10T10:00:00Z', 'docs: review document for #700', { + 'docs/reviews/CODE-REVIEW-700-r1.md': doc('CODE-REVIEW-700-r1', usage(1000, 100)), + 'docs/reviews/SPEC-REVIEW-701-r1.md': doc('SPEC-REVIEW-701-r1', ''), + }); + // Внутри окна: документ с расходом и документ без данных. + commitAt('2026-09-26T10:00:00Z', 'docs: review document for #702', { + 'docs/reviews/CODE-REVIEW-702-r1.md': doc('CODE-REVIEW-702-r1', usage(20, 2)), + 'docs/reviews/SPEC-REVIEW-703-r1.md': doc('SPEC-REVIEW-703-r1', ''), + }); + // Внутри окна: перенос документов вне окна в архив (#682) — переименование, не добавление. + mkdirSync(join(dir, 'legacy/reviews/v1.0.0'), { recursive: true }); + git(['mv', 'docs/reviews/CODE-REVIEW-700-r1.md', 'legacy/reviews/v1.0.0/CODE-REVIEW-700-r1.md']); + git(['mv', 'docs/reviews/SPEC-REVIEW-701-r1.md', 'legacy/reviews/v1.0.0/SPEC-REVIEW-701-r1.md']); + commitAt('2026-09-28T10:00:00Z', 'chore(legacy): archive the v1.0.0 review documents', {}); + // После окна: документ с расходом (отчёт за прошлую неделю его не видит). + commitAt('2026-10-02T10:00:00Z', 'docs: review document for #704', { 'docs/reviews/CODE-REVIEW-704-r1.md': doc('CODE-REVIEW-704-r1', usage(5, 5)) }); + + const added = reviewDocAddedAt(git(['log', '-M', '--diff-filter=AR', '--reverse', '--name-status', '--format=%x1e%cI', 'HEAD', '--', 'docs/reviews', 'legacy/reviews'])); + assert.equal(Date.parse(added.get('legacy/reviews/v1.0.0/CODE-REVIEW-700-r1.md')), Date.parse('2026-09-10T10:00:00Z'), 'перенос в архив сохраняет дату добавления'); + assert.equal(added.has('docs/reviews/CODE-REVIEW-700-r1.md'), false, 'прежнего пути в HEAD нет'); + + const gh = (args) => (String(args[1]).includes('/actions/runs?') ? { workflow_runs: [] } : []); + const snap = fetchSnapshot({ repo: 'o/r', since, until, gh, git: (args) => git(args) }); + assert.equal(snap.reviewDocs.length, 5, 'тексты читаются из HEAD, как раньше'); + const report = buildReport({ since, until, ...snap }); + assert.equal(report.tokens.docs, 1, 'AC1: только документ, добавленный в окне'); + assert.deepEqual([report.tokens.totals.input_tokens, report.tokens.totals.output_tokens], [20, 2]); + assert.equal(report.tokens.missing, 1, 'AC3: hp:usage-none — по тому же окну'); + const md = renderMarkdown(report); + const section = md.slice(md.indexOf('### Токены'), md.indexOf('###', md.indexOf('### Токены') + 3)); + assert.match(section, /^Документы ревью, добавленные в `dev` за окно отчёта/m); + assert.match(section, /^Токены по 1 документам ревью: input_tokens 20 · output_tokens 2 /m); + assert.match(section, /^Без данных о расходе: 1\.$/m); + + // Неделя, в которую документ #700 был добавлен, его видит — в архиве он или нет. + const early = buildReport({ since: '2026-09-07T00:00:00Z', until: '2026-09-14T00:00:00Z', ...snap }); + assert.deepEqual([early.tokens.docs, early.tokens.totals.input_tokens, early.tokens.missing], [1, 1000, 1]); + // Без карты дат (юнит над готовыми документами) окна нет — как до #761. + assert.equal(tokenDocs(snap.reviewDocs).length, 5); + assert.equal(tokenDocs(snap.reviewDocs, { added: new Map(), since, until }).length, 0, 'добавление не найдено — вне окна'); +});