Files
houseplan-card/test/process-track.test.mjs
T
Claudeandclaude[bot] c23bbcd7d9 fix(process): import and type-only lines no longer raise risk (#755)
The risk table of #707 judged every non-comment line of a class A file as
code. On the history since 15.09 that raised #741 from ship to show for a
removed interface member that never reaches JS, and put false classes on
#624 (removed imports), #693/#694 (stairs-view is rendering, not geometry)
and #725 (the config fingerprint memo is not the config schema).

- Lines of module syntax and TypeScript types give no risk, like comments:
  `import …`, `export … from …`, `export type …`, the head of `interface X`
  or `type X =`, and the lines inside such a block (indented, plus the
  closing line). The block state per side of a change block starts from the
  hunk context git writes after `@@ … @@` and follows every unindented line
  of the block, so a member under `@@ … @@ export interface X {` and a whole
  interface added in one hunk are judged alike. Only `.ts`, and not in the
  `migration` area: there the types are the config contract (#588, #649).
- `stairs*` is narrowed to the stairs model (`stairs`, `stairs-box`,
  `stairs-editor-model`); `config-*` to writing and adopting the config
  (`config-adoption`, `config-store`, `config-reload-authority`,
  `config-write-conflict`).
- A replaced line is one piece of evidence: a removed line whose counterpart
  in the same change block hits the same class is folded into it instead of
  printing `path:N (удалена)` next to `path:N`.

classifyRisk stays a pure function over the diff text. On the history the
raising classes change for #741 (none), #693 (visual only), #694 (no
geometry), #725 (perf only) and #624 (no devices/perf); migration on #588,
#612, #649 and #661 stays. PROCESS.md §5 names the new exemption.

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

1238 lines
101 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
COMPARE_FILES_CAP, RISK_CLASSES, RISK_NOTE_LINE_LIMIT, SHIP_SRC_LINE_LIMIT, SHOW_CRITERIA, classifyRisk, cycleLimit, decideTrack,
explicitTracks, guardLimit, hasTrackLabel, parseNameStatus, parseNumstat, rebaseBeforeReview, resolveTrack, reviewRoute, riskClassLine,
riskNote, routeComment, routeNote, routeSummary, shipLimitViolations, trackFromLabels, trackOrigin,
} from '../scripts/process-track.mjs';
import { reviewCounters, reviewRoundsFromFiles, withMaterialAnchors } from '../scripts/review-doc-guard.mjs';
import { sectionText } from '../scripts/md-anchors.mjs';
import { classify as classifyPath } from '../scripts/change-classes.mjs';
// Пути монолитов — данные для классификатора, а не чтение их текста (#624).
import { CARD_FILE, RUNTIME_FILE } from '../scripts/monolith-metrics.mjs';
import { trackFromLabels as packetTrack } from '../scripts/task-packet.mjs';
// #696: конвейер ревью решает цену захода по треку; трек и рамки ship —
// механические, потому что по ним задача сливается без ревью модели.
const SCRIPT = join(dirname(fileURLToPath(import.meta.url)), '..', 'scripts', 'process-track.mjs');
test('пакет задачи и конвейер читают трек одной функцией (#696)', () => {
assert.equal(packetTrack, trackFromLabels);
});
test('явная трековая метка главнее признака инфраструктуры (#696)', () => {
const infra = ['scripts/x.mjs', '.github/workflows/y.yml'];
assert.deepEqual(resolveTrack({ labels: ['track:ask'], files: infra }), { track: 'ask', mutants: false, full: false, infrastructure: true });
assert.deepEqual(resolveTrack({ labels: ['track:ship'], files: ['src/a.ts'] }), { track: 'ship', mutants: false, full: false, infrastructure: false });
assert.equal(resolveTrack({ labels: ['small'], files: ['src/a.ts'] }).track, 'show');
});
test('без трековой метки инфраструктура — show, продуктовая задача — ask (#696)', () => {
assert.equal(resolveTrack({ labels: ['infra'], files: ['scripts/x.mjs'] }).track, 'show');
assert.equal(resolveTrack({ labels: ['bug'], files: ['scripts/x.mjs', 'src/a.ts'] }).track, 'ask');
assert.equal(resolveTrack({ labels: [], files: [] }).track, 'ask', 'пустой дифф не доказывает инфраструктуру');
assert.equal(hasTrackLabel(['bug', 'P2']), false);
assert.equal(hasTrackLabel(['trivial']), true);
});
test('#709: мутантов в разработке нет ни на одном треке и ни по какой метке', () => {
for (const labels of [['track:ship'], ['track:show'], ['track:ask'], ['track:show', 'ci:mutants'], ['ci:mutants'], []]) {
assert.equal(resolveTrack({ labels, files: ['src/a.ts'] }).mutants, false, labels.join(',') || 'без меток');
}
});
test('рамки ship: строки src/** считаются вместе, граница включительна (#696)', () => {
const at = (added, deleted, path = 'src/a.ts') => ({ added, deleted, path });
assert.deepEqual(shipLimitViolations({ numstat: [at(20, 10)] }), []);
assert.equal(SHIP_SRC_LINE_LIMIT, 30);
const over = shipLimitViolations({ numstat: [at(20, 5), at(4, 2, 'src/b.ts')] });
assert.equal(over.length, 1);
assert.match(over[0], /31 строк/);
assert.deepEqual(shipLimitViolations({ numstat: [at(500, 0, 'scripts/x.mjs'), at(200, 0, 'test/x.test.mjs')] }), [],
'тесты и скрипты рамку src не расходуют');
});
test('рамки ship: новые файлы, двоичные, i18n, конфиг и Python (#696)', () => {
const v = shipLimitViolations({
numstat: [
{ added: null, deleted: null, path: 'src/icon.png' },
{ added: 1, deleted: 0, path: 'src/i18n/ru.json' },
{ added: 1, deleted: 0, path: 'src/types.ts' },
{ added: 1, deleted: 0, path: 'src/config-editor.ts' },
{ added: 1, deleted: 0, path: 'custom_components/houseplan/__init__.py' },
{ added: 1, deleted: 0, path: 'custom_components/houseplan/translations/en.json' },
],
nameStatus: [{ status: 'A', path: 'src/new-thing.ts' }, { status: 'M', path: 'src/types.ts' }],
});
assert.equal(v.length, 5, v.join('\n'));
assert.ok(v.some((s) => s.startsWith('двоичные') && s.includes('src/icon.png')));
assert.ok(v.some((s) => s.startsWith('новые файлы') && s.includes('src/new-thing.ts')));
assert.ok(v.some((s) => s.startsWith('ключи i18n') && s.includes('src/i18n/ru.json') && s.includes('translations/en.json')));
assert.ok(v.some((s) => s.startsWith('поля конфига') && s.includes('src/types.ts') && s.includes('src/config-editor.ts')));
assert.ok(v.some((s) => s.startsWith('Python')));
assert.deepEqual(shipLimitViolations({ nameStatus: [{ status: 'A', path: 'test/new.test.mjs' }] }), [],
'новый тест вне src рамки не нарушает');
});
test('разбор numstat и name-status: двоичный файл и переименование (#696)', () => {
assert.deepEqual(parseNumstat('3\t1\tsrc/a.ts\n-\t-\tsrc/i.png\n'), [
{ added: 3, deleted: 1, path: 'src/a.ts' }, { added: null, deleted: null, path: 'src/i.png' },
]);
assert.deepEqual(parseNameStatus('M\tsrc/a.ts\nR100\tsrc/old.ts\tsrc/new.ts\n'), [
{ status: 'M', path: 'src/a.ts' }, { status: 'R100', path: 'src/new.ts' },
]);
});
test('CLI ship-limits и resolve читают реальный дифф (#696)', (t) => {
const dir = mkdtempSync(join(tmpdir(), 'hp-track-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const git = (...args) => {
const r = spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
assert.equal(r.status, 0, r.stderr);
return r.stdout.trim();
};
git('init', '-q', '-b', 'main');
git('config', 'user.email', 't@t'); git('config', 'user.name', 't');
git('config', 'core.hooksPath', '/dev/null');
mkdirSync(join(dir, 'src'));
writeFileSync(join(dir, 'src', 'a.ts'), 'a\n');
git('add', '.'); git('commit', '-q', '-m', 'base');
const base = git('rev-parse', 'HEAD');
writeFileSync(join(dir, 'src', 'a.ts'), 'a\nb\n');
git('commit', '-q', '-am', 'small');
const run = (...args) => spawnSync(process.execPath, [SCRIPT, ...args], { cwd: dir, encoding: 'utf8', env: { ...process.env, GITHUB_OUTPUT: '' } });
let r = run('ship-limits', `--base=${base}`);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /^ship=true$/m);
r = run('resolve', '--labels=track:show', `--base=${base}`);
assert.match(r.stdout, /^track=show$/m);
assert.match(r.stdout, /^mutants=false$/m);
writeFileSync(join(dir, 'src', 'b.ts'), 'x\n');
git('add', '.'); git('commit', '-q', '-m', 'new file');
r = run('ship-limits', `--base=${base}`);
assert.match(r.stdout, /^ship=false$/m);
assert.match(r.stdout, /новые файлы в src\/\*\*: src\/b\.ts/);
r = run('ship-limits');
assert.notEqual(r.status, 0, 'без базы рамки не проверяются');
});
// ---------- конвейер читает трек (#696) ----------
const WORKFLOW = join(dirname(fileURLToPath(import.meta.url)), '..', '.github', 'workflows', '_process.yml');
test('конвейер: трек снимается до ребейза, мутанты и ship идут из него (#696)', async () => {
const { readFileSync } = await import('node:fs');
const workflow = readFileSync(WORKFLOW, 'utf8');
const at = (marker) => { const i = workflow.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; };
const branch = at(' - name: Перейти на ветку задачи\n');
const track = at(' - name: "Трек задачи и рамки ship (#696)"\n');
const rebase = at(' - name: Привести ветку к dev\n');
const gate = at(' - name: Validate на материале\n');
assert.ok(branch < track && track < rebase && rebase < gate, 'трек — после выбора ветки и до ребейза');
const trackStep = workflow.slice(track, rebase);
assert.match(trackStep, /LABELS: \$\{\{ needs\.guard\.outputs\.labels \}\}/, 'метки — текущие, из guard');
assert.match(trackStep, /git archive origin\/dev scripts \| tar -x -C "\$tools"/, 'скрипт — из dev: ветка show/ship не ребейзится');
// #707: один вызов скрипта решает трек, рамки ship и риск; bash только исполняет.
assert.equal((trackStep.match(/process-track\.mjs/g) || []).length, 1, 'скрипт трека вызывается один раз');
assert.match(trackStep, /node "\$tools\/scripts\/process-track\.mjs" stage --stage="\$STAGE" --labels="\$LABELS" \\\n\s+--branch="\$BRANCH" --base=origin\/dev --head=HEAD --comments="\$comments" --owner="\$OWNER"/);
assert.match(trackStep, /if printf '%s\\n' "\$out" \| grep -qx 'raise=true'; then\n\s+gh issue comment "\$NUM" --repo "\$\{\{ github\.repository \}\}" --body-file "\$RUNNER_TEMP\/track\/raise\.md"/,
'комментарий повышения — из файла скрипта, только по его флагу');
assert.match(trackStep, /--add-label track:show --remove-label track:ship/, 'выход за рамки повышает трек');
assert.doesNotMatch(trackStep, /ship=true|track=show|>> "\$GITHUB_OUTPUT"/, 'выходы шага пишет скрипт, а не bash');
assert.match(workflow, /labels=\$\(printf '%s\\n' "\$current" \| paste -sd, -\)/, 'guard отдаёт текущие метки');
const rebaseStep = workflow.slice(rebase, at(' - name: Зафиксировать SHA материала ревью\n'));
assert.match(rebaseStep, /if \[ "\$TRACK" = "show" \] \|\| \[ "\$TRACK" = "ship" \]; then\n\s+if git merge-tree --write-tree origin\/dev HEAD/,
'show/ship не ребейзятся при чистом слиянии');
const gateStep = workflow.slice(gate, at(' - name: Validate идёт — раунд продолжит событие\n'));
assert.match(gateStep, /--mutants="\$\{MUTANTS:-false\}"/, 'по умолчанию — без мутантов (#709)');
assert.match(gateStep, /MUTANTS: \$\{\{ steps\.track\.outputs\.mutants \}\}/);
});
test('конвейер: ship в рамках сливается без модели, с маркером для пакетного ревью (#696)', async () => {
const { readFileSync } = await import('node:fs');
const workflow = readFileSync(WORKFLOW, 'utf8');
const modelJob = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n integrate:'));
assert.match(modelJob, /if: needs\.prepare\.outputs\.proceed == 'true' && needs\.prepare\.outputs\.reuse != 'true' && needs\.prepare\.outputs\.ship != 'true'/);
const integrate = workflow.slice(workflow.indexOf('\n integrate:'));
for (const step of ['Получить результат модели', 'Опубликовать документ ревью', '"Материал раунда воспроизводим (#413)"']) {
const i = integrate.indexOf(`- name: ${step}`);
assert.ok(i > 0, step);
assert.match(integrate.slice(i, i + 400), /needs\.prepare\.outputs\.ship != 'true'/, `${step} пропускается на ship`);
}
const decide = integrate.slice(integrate.indexOf('- name: Решение по вердикту'), integrate.indexOf('- name: dev ушёл вперёд'));
assert.match(decide, /if \[ "\$SHIP" = "true" \] && \[ "\$REUSE" != "true" \]; then[\s\S]*verdict=green; high=0/);
assert.match(decide, /<!-- hp:ship-merge material=\$MATERIAL -->/, 'маркер для ship-review.mjs');
assert.doesNotMatch(decide.slice(decide.indexOf('SHIP" = "true'), decide.indexOf('elif [ "$REUSE"')), /Вердикт:/,
'слияние без ревью не называет себя вердиктом ревью');
const merge = integrate.slice(integrate.indexOf('- name: Слить ветку в dev'), integrate.indexOf('- name: Переставить метку'));
assert.match(merge, /--mutants="\$\{MUTANTS:-false\}"/);
const env = modelJob.slice(modelJob.indexOf('- name: Что ревьюеру нужно из окружения'), modelJob.indexOf('- name: Установить Claude Code'));
assert.match(env, /if \[ "\$STAGE" = "spec" \]; then deps=false; browser=false; fi/, 'ревью ТЗ не ставит окружение');
assert.match(env, /if: steps\.env_needs\.outputs\.deps == 'true'\n\s+run: npm ci/);
assert.match(env, /if: steps\.env_needs\.outputs\.browser == 'true' && steps\.pw\.outputs\.cache-hit != 'true'/);
});
test('#697: полный набор на ветке задачи — только по меткам ci:full и ci:golden', () => {
assert.equal(resolveTrack({ labels: ['track:show'], files: ['src/a.ts'] }).full, false);
assert.equal(resolveTrack({ labels: ['track:show', 'ci:golden'], files: ['src/a.ts'] }).full, true);
assert.equal(resolveTrack({ labels: ['track:ask', 'ci:full'], files: ['src/a.ts'] }).full, true);
assert.equal(resolveTrack({ labels: ['ci:mutants'], files: ['src/a.ts'] }).full, false, 'мутанты полного набора не заказывают');
});
test('#697: конвейер передаёт полный набор гейту материала', async () => {
const { readFileSync } = await import('node:fs');
const workflow = readFileSync(WORKFLOW, 'utf8');
const gate = workflow.slice(workflow.indexOf(' - name: Validate на материале\n'), workflow.indexOf(' - name: Validate идёт — раунд продолжит событие\n'));
assert.match(gate, /FULL: \$\{\{ steps\.track\.outputs\.full \}\}/);
assert.match(gate, /--full="\$\{FULL:-false\}"/);
// #707: `full` пишет в GITHUB_OUTPUT скрипт трека — из меток и только из них.
assert.match(readFileSync(SCRIPT, 'utf8'), /`full=\$\{decision\.full\}`/);
});
// ---------- #707: риск по изменённым участкам, основание трека, одно правило ----------
/** Дифф `git diff --unified=0` из строк: `add`/`del` — пары [номер, текст]. */
function diffOf(files) {
return `${files.map(({ path, oldPath = path, add = [], del = [] }) => {
const out = [`diff --git a/${oldPath} b/${path}`, `--- a/${oldPath}`, `+++ b/${path}`];
for (const [line, text] of del) out.push(`@@ -${line} +${line - 1},0 @@`, `-${text}`);
for (const [line, text] of add) out.push(`@@ -${line - 1},0 +${line} @@`, `+${text}`);
return out.join('\n');
}).join('\n')}\n`;
}
const only = (risk) => ({ classes: risk.classes, evidence: risk.evidence });
test('#707 AC1: классификатор риска — точные классы и доказательства путь:строка', () => {
const card = CARD_FILE;
// (а) токен в монолите — touch; удаление такой строки — тоже touch.
assert.deepEqual(only(classifyRisk(diffOf([{ path: card, add: [[12, " this.addEventListener('pointerdown', onDown);"]] }]))),
{ classes: ['touch'], evidence: { touch: [`${card}:12 · токен pointerdown`] } });
assert.deepEqual(only(classifyRisk(diffOf([{ path: card, del: [[40, " el.removeEventListener('pointerdown', onDown);"]] }]))),
{ classes: ['touch'], evidence: { touch: [`${card}:40 (удалена) · токен pointerdown`] } });
// (б) строка-комментарий в участке geometry — ничего; код там же — geometry по участку.
const wall = 'src/wall-merge.ts';
for (const comment of ['// pointerdown snapToGrid thickness', '/* merge */', ' * @param wall', ' */', '<!-- x -->', ' ']) {
assert.deepEqual(classifyRisk(diffOf([{ path: wall, add: [[5, comment]] }])).classes, [], JSON.stringify(comment));
}
assert.deepEqual(only(classifyRisk(diffOf([{ path: wall, add: [[5, ' const merged = a + b;']] }]))),
{ classes: ['geometry'], evidence: { geometry: [`${wall}:5 · участок wall-*`] } });
// (в) snapshot — не geometry; snapToGrid — geometry по токену даже в монолите.
assert.deepEqual(classifyRisk(diffOf([{ path: card, add: [[3, ' const snapshot = this._snapshot();']] }])).classes, []);
assert.deepEqual(only(classifyRisk(diffOf([{ path: card, add: [[3, ' const p = snapToGrid(pt, 5);']] }]))),
{ classes: ['geometry'], evidence: { geometry: [`${card}:3 · токен snapToGrid`] } });
// (г) новый ключ i18n — ux; смена только значения — ничего.
assert.deepEqual(only(classifyRisk(diffOf([{ path: 'src/i18n/en.json', add: [[9, ' "save_plan": "Save plan",']] }]))),
{ classes: ['ux'], evidence: { ux: ['src/i18n/en.json:9 · новый ключ "save_plan"'] } });
assert.deepEqual(classifyRisk(diffOf([{ path: 'src/i18n/en.json', del: [[9, ' "save_plan": "Save",']], add: [[9, ' "save_plan": "Save plan",']] }])).classes, []);
// (д) callService в участке devices — devices по участку и токену.
assert.deepEqual(only(classifyRisk(diffOf([{ path: 'src/device-toggle.ts', add: [[3, " hass.callService('light', 'toggle', data);"]] }]))),
{ classes: ['devices'], evidence: { devices: ['src/device-toggle.ts:3 · участок device-toggle, токен callService'] } });
// (е) те же токены вне класса A — ничего.
for (const path of ['test/touch.test.mjs', 'scripts/tool.mjs', 'docs/TOUCH-SUPPORT.md', 'demo/smoke_x.mjs']) {
assert.deepEqual(classifyRisk(diffOf([{ path, add: [[1, "on('pointerdown', () => hass.callService(snapToGrid(requestAnimationFrame)))"]] }])).classes, [], path);
}
// (ж) декларация стиля интерфейса — visual/ui, ship не повышает.
const ui = classifyRisk(diffOf([{ path: 'src/styles/plan.styles.ts', add: [[7, ' gap: 4px;']] }]));
assert.deepEqual(ui.classes, ['visual']);
assert.deepEqual(ui.raising, []);
assert.deepEqual(ui.visual, { render: false, ui: true });
assert.deepEqual(ui.evidence.visual, ['src/styles/plan.styles.ts:7 · участок src/styles/** (ui)']);
// (з) путь отрисовки плана — visual/render и perf.
const render = classifyRisk(diffOf([{ path: 'src/render/paper-scene.ts', add: [[3, ' const scale = 2;']] }]));
assert.deepEqual(only(render), {
classes: ['perf', 'visual'],
evidence: { perf: ['src/render/paper-scene.ts:3 · участок src/render/**'], visual: ['src/render/paper-scene.ts:3 · участок src/render/** (render)'] },
});
assert.deepEqual(render.visual, { render: true, ui: false });
assert.deepEqual(render.raising, ['perf']);
// (и) чистое переименование — ханков нет, риска нет.
const rename = 'diff --git a/src/wall-merge.ts b/src/wall-merge-core.ts\nsimilarity index 100%\nrename from src/wall-merge.ts\nrename to src/wall-merge-core.ts\n';
assert.deepEqual(classifyRisk(rename).classes, []);
// Двоичный файл ханков не даёт — его ловят рамки ship.
assert.deepEqual(classifyRisk('diff --git a/src/render/icon.png b/src/render/icon.png\nindex 1..2 100644\nBinary files a/src/render/icon.png and b/src/render/icon.png differ\n').classes, []);
// (к) переводы интеграции — ux; strings.json класса «?» не судится.
const key = ' "zone_name": "Zone",';
assert.deepEqual(only(classifyRisk(diffOf([{ path: 'custom_components/houseplan/translations/en.json', add: [[4, key]] }]))),
{ classes: ['ux'], evidence: { ux: ['custom_components/houseplan/translations/en.json:4 · новый ключ "zone_name"'] } });
assert.deepEqual(classifyRisk(diffOf([{ path: 'custom_components/houseplan/strings.json', add: [[4, key]] }])).classes, []);
});
test('#707 AC1: каждая строка таблицы риска — положительный и отрицательный случай', () => {
const cls = (path, add, del = []) => classifyRisk(diffOf([{ path, add, del }])).classes;
const card = CARD_FILE;
const cases = [
// [класс, положительный участок, положительный токен, отрицательный]
['geometry', ['src/opening-placement.ts', 'x = 1;'], [card, 'const t = wallThickness;'], [card, 'const s = snapshotOf(x);']],
['geometry', ['custom_components/houseplan/junction_limits.py', 'x = 1'], ['src/logic.ts', 'canonicalizePoint(p);'], ['custom_components/houseplan/junction_limits.py', '# junction comment']],
['touch', ['src/pointer-modality.ts', 'x = 1;'], [card, "style: 'touch-action: none'"], ['src/logic.ts', 'const pointerUpdate = 1;']],
['migration', ['src/config-store.ts', 'x = 1;'], [card, 'migrateLegacy(cfg);'], ['scripts/migrate.mjs', 'migrateLegacy(cfg);']],
['migration', ['custom_components/houseplan/store.py', 'x = 1'], ['custom_components/houseplan/plans.py', 'STORAGE_VERSION = 3'], ['custom_components/houseplan/store.py', ' ']],
['devices', ['src/vacuum-routes.ts', 'x = 1;'], [card, "hass.callService('a', 'b');"], ['docs/x.md', "hass.callService('a', 'b');"]],
['devices', ['custom_components/houseplan/auth.py', 'x = 1'], ['custom_components/houseplan/plans.py', 'require_admin(user)'], ['custom_components/houseplan/plans.py', 'user = 1']],
['perf', ['src/glow-scene.ts', 'x = 1;'], [card, 'const r = el.getBoundingClientRect();'], [card, '// getBoundingClientRect']],
['perf', ['src/boot-soft-layout.ts', 'x = 1;'], ['src/logic.ts', ' filter: blur(2px);'], ['src/logic.ts', 'list.filter((x) => x);']],
['ux', null, [card, "customElements.define('hp-x', X);"], [card, "customElements.get('hp-x');"]],
['visual', ['src/iso-walls.ts', 'x = 1;'], [card, '<path stroke-width="2" d="M0 0">'], [card, 'const strokeWidthPx = 2;']],
['visual', ['src/summary-panel-style.ts', 'x = 1;'], ['src/furniture-plan-art.generated.ts', 'x = 1;'], [card, 'const styles = 1;']],
];
for (const [name, area, token, negative] of cases) {
if (area) assert.ok(cls(area[0], [[1, area[1]]]).includes(name), `${name}: участок ${area[0]}`);
assert.ok(cls(token[0], [[1, token[1]]]).includes(name), `${name}: ${token[0]} «${token[1]}»`);
assert.ok(!cls(negative[0], [[1, negative[1]]]).includes(name), `${name}: не должно — ${negative[0]} «${negative[1]}»`);
}
// ux: удалённый customElements.define и удалённый ключ риска не дают.
assert.deepEqual(cls(card, [], [[3, "customElements.define('hp-x', X);"]]), []);
assert.deepEqual(cls('src/i18n/settings/ru.json', [], [[3, ' "a": "b",']]), []);
assert.deepEqual(cls('src/i18n/settings/ru.json', [[3, ' "a": "b",']]), ['ux'], 'src/i18n/**/*.json');
// монолит участком не судится: нейтральная строка — без классов.
assert.deepEqual(cls(RUNTIME_FILE, [[3, 'const a = 1;']]), []);
// текст перевода со словом токена — не геометрия.
assert.deepEqual(cls('src/i18n/en.json', [[3, ' "wall_thickness": "Wall thickness",']], [[3, ' "wall_thickness": "Thickness",']]), []);
});
test('#707 AC1: большой дифф — классифицируются все ханки, печатается не больше пяти', () => {
const add = Array.from({ length: 12 }, (_, i) => [i + 1, ` el.addEventListener('pointermove', f${i});`]);
const risk = classifyRisk(diffOf([{ path: CARD_FILE, add }]));
assert.equal(risk.counts.touch, 12);
assert.equal(risk.evidence.touch.length, 5);
assert.match(riskClassLine(risk, 'touch'), /; и ещё 7$/);
});
// ---------- #755: строки модулей и типов, участки уже, заменённая строка ----------
/** Дифф одного файла из ханков: заголовок `@@ … @@ <контекст>` и строки `-`/`+` как есть. */
function hunksOf(path, hunks) {
return `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n${hunks.map(([head, ...rows]) => [head, ...rows].join('\n')).join('\n')}\n`;
}
const ISO = 'src/iso-scene-render.ts';
test('#755 AC1: строки импорта и только типов TypeScript риска не дают, кроме участка migration', () => {
// Ханк #741: удалённый член интерфейса под заголовком интерфейса — классов нет.
const member = '- stageSize?: { width: number; height: number } | null;';
assert.deepEqual(classifyRisk(hunksOf(ISO, [['@@ -651 +650,0 @@ export interface IsoOverlayFitEnvelopeInput {', member]])).classes, []);
assert.deepEqual(classifyRisk(hunksOf(ISO, [['@@ -134 +133,0 @@ export type IsoOverlayRenderEntry = {', '- groundRadius: number;']])).classes, []);
// То же тело под заголовком класса — код, участок iso-scene-render: perf.
const inClass = classifyRisk(hunksOf(ISO, [['@@ -651 +650,0 @@ export class X {', member]]));
assert.deepEqual(inClass.raising, ['perf']);
assert.deepEqual(inClass.evidence.perf, [`${ISO}:651 (удалена) · участок iso-scene-render`]);
// Под закрытой однострочной декларацией строка с отступом — снова код.
assert.deepEqual(classifyRisk(hunksOf(ISO, [["@@ -9,0 +10 @@ export type Mode = 'a' | 'b';", '+ run(x);']])).raising, ['perf']);
// Оператор импорта, член многострочного `import {`, его закрывающая строка, реэкспорт.
const modules = [
["@@ -9 +8,0 @@ import { nothing, svg, type TemplateResult } from 'lit';", "-import { guard } from 'lit/directives/guard.js';"],
['@@ -16 +16 @@ import {', '- cachedStairRenderGeometry, stairList,', '+ cachedStairMarkup, cachedStairRenderGeometry, stairList,'],
['@@ -20 +20 @@ import {', "-} from './stairs';", "+} from './stairs-model';"],
['@@ -30,0 +31,2 @@ import { x } from "./x";', "+export { computeIsoSunBeams } from './iso-sun';", "+export * from './iso-tiles';"],
['@@ -40,0 +42,2 @@ import { x } from "./x";', "+import type { Rect } from './geometry';", "+export type { Rect } from './geometry';"],
];
for (const hunk of modules) assert.deepEqual(classifyRisk(hunksOf(ISO, [hunk])).classes, [], hunk.join(' ⏎ '));
// Импорт с токеном в монолите — тоже нет; тот же токен в коде монолита — migration (таблица #707).
assert.deepEqual(classifyRisk(diffOf([{ path: CARD_FILE, add: [[3, "import { migrateX } from './m';"]] }])).classes, []);
assert.deepEqual(classifyRisk(diffOf([{ path: CARD_FILE, add: [[3, 'migrateX(cfg);']] }])).classes, ['migration']);
// Динамический импорт и `import.meta` — код.
assert.deepEqual(classifyRisk(diffOf([{ path: ISO, add: [[3, "import('./iso-sun').then(run);"]] }])).raising, ['perf']);
assert.deepEqual(classifyRisk(diffOf([{ path: ISO, add: [[3, 'import.meta.hot?.accept();']] }])).raising, ['perf']);
// Целиком добавленный интерфейс: заголовок ханка — прошлая декларация, голова внутри ханка.
const wholeType = ['@@ -330,0 +331,5 @@ export function cachedStairRenderGeometry(',
'+/** Attribute strings. */', '+export interface StairMarkup {', '+ outline: string;', '+ treads: string;', '+}'];
assert.deepEqual(classifyRisk(hunksOf(ISO, [wholeType])).classes, []);
// Блок типа закрыт — следующая строка кода того же ханка судится.
const typeThenCode = ['@@ -1,0 +2,4 @@ export const a = 1;', '+type Box = {', '+ w: number;', '+};', '+export const box = measure();'];
assert.deepEqual(classifyRisk(hunksOf(ISO, [typeThenCode])).evidence.perf, [`${ISO}:5 · участок iso-scene-render`]);
// Дифф с контекстом: следующий блок ханка берёт контекст из строки контекста, как git.
const withContext = ['@@ -10,5 +10,5 @@ export interface A {', '- a: number;', '+ a: string;', ' }',
' export function f() {', '- run(1);', '+ run(2);'];
assert.deepEqual(classifyRisk(hunksOf(ISO, [withContext])).evidence.perf, [`${ISO}:13 · участок iso-scene-render`]);
// К3: участок migration — типы конфига и импорты судятся, как раньше (#588, #649).
const marker = classifyRisk(hunksOf('src/types.ts', [['@@ -199 +199 @@ export interface Marker {',
"- display?: 'badge' | 'ripple';", "+ display?: 'badge' | 'ripple' | 'value';"]]));
assert.deepEqual(only(marker), { classes: ['migration'], evidence: { migration: ['src/types.ts:199 · участок types'] } });
assert.deepEqual(classifyRisk(hunksOf('src/types.ts', [['@@ -304,0 +305 @@ export interface ServerConfig {', '+ volumetric_view?: boolean;']])).classes, ['migration']);
assert.deepEqual(classifyRisk(diffOf([{ path: 'src/config-store.ts', add: [[2, "import { normalize } from './n';"]] }])).classes, ['migration']);
// Только `.ts`: строка Python с `import` и `type` судится, как раньше.
assert.deepEqual(classifyRisk(diffOf([{ path: 'custom_components/houseplan/auth.py', add: [[2, 'import hass']] }])).classes, ['devices']);
});
test('#755 AC2: участки stairs и config — модель лестницы и запись конфига, а не всё по префиксу', () => {
const cls = (path, text = ' const a = b + c;') => classifyRisk(diffOf([{ path, add: [[5, text]] }])).classes;
assert.deepEqual(cls('src/stairs-view.ts'), ['visual'], 'отрисовка лестницы — visual:render, не geometry');
assert.deepEqual(cls('src/stairs-editor.ts'), [], 'нейтральная строка редактора лестницы');
assert.deepEqual(cls('src/stairs-editor.ts', " el.addEventListener('pointerdown', onDown);"), ['touch'], 'указатель редактора — токен touch');
for (const path of ['src/stairs.ts', 'src/stairs-box.ts', 'src/stairs-editor-model.ts']) assert.deepEqual(cls(path), ['geometry'], path);
assert.deepEqual(cls('src/config-fingerprint-pass.ts'), [], 'мемо отпечатка о схеме не знает');
for (const path of ['src/config-store.ts', 'src/config-adoption.ts', 'src/config-reload-authority.ts', 'src/config-write-conflict.ts']) {
assert.deepEqual(cls(path), ['migration'], path);
}
});
test('#755: заменённая строка — одно доказательство, удалённая без пары остаётся «(удалена)»', () => {
const view = 'src/stairs-view.ts';
const replaced = classifyRisk(hunksOf(view, [['@@ -89,2 +89 @@ export function renderStairs(',
'- const cls = "a";', '- const old = 1;', '+ const cls = "b";']]));
assert.deepEqual(replaced.evidence.visual, [
`${view}:90 (удалена) · участок stairs-view (render)`,
`${view}:89 · участок stairs-view (render)`,
]);
assert.equal(replaced.counts.visual, 2);
// Пара сводит и правила: удалённый токен дописывается к новой строке.
const tokens = classifyRisk(hunksOf(CARD_FILE, [['@@ -12 +12 @@ export class HouseplanCard extends LitElement {',
"- el.addEventListener('pointerdown', f);", "+ el.addEventListener('pointerup', f);"]]));
assert.deepEqual(tokens.evidence.touch, [`${CARD_FILE}:12 · токен pointerup, токен pointerdown`]);
// Новая строка класса не даёт — удалённая остаётся своим доказательством.
const gone = classifyRisk(hunksOf(CARD_FILE, [['@@ -12 +12 @@ export class HouseplanCard extends LitElement {',
"- el.addEventListener('pointerdown', f);", '+ el.focus();']]));
assert.deepEqual(gone.evidence.touch, [`${CARD_FILE}:12 (удалена) · токен pointerdown`]);
// Разные ханки не пара, даже с одним номером строки (таблица #707 строит именно такие).
assert.equal(classifyRisk(diffOf([{ path: view, del: [[9, 'x = 1;']], add: [[9, 'x = 2;']] }])).counts.visual, 2);
});
test('#707 AC2: происхождение трека — строка владельца, предложение, прежние метки', () => {
const c = (author, body, createdAt) => ({ author, body, createdAt });
const owner = 'Matysh';
const ship = ['track:ship', 'S7-code-review'];
const confirmed = trackOrigin({ labels: ship, owner, comments: [c(owner, 'Решение.\nТрек: ship — решение владельца', '2026-09-30T08:00:00Z')] });
assert.equal(confirmed.confirmed, true);
assert.equal(confirmed.basis, 'метка, подтверждённая владельцем (2026-09-30)');
for (const line of ['трек: SHIP – Решение Владельца', 'Трек: ship - решение владельца', ' Трек:ship—решение владельца']) {
assert.equal(trackOrigin({ labels: ship, owner, comments: [c('matysh', line, '1')] }).confirmed, true, line);
}
const proposal = 'метка без подтверждения — предложение';
assert.equal(trackOrigin({ labels: ship, owner, comments: [c('claude[bot]', 'Трек: ship — решение владельца', '1')] }).basis, proposal, 'чужой автор');
assert.equal(trackOrigin({ labels: ship, owner, comments: [c(owner, 'Трек: show — решение владельца', '1')] }).basis, proposal, 'другой трек');
assert.equal(trackOrigin({ labels: ship, owner, comments: [c(owner, 'как сказано: Трек: ship — решение владельца', '1')] }).basis, proposal, 'не в начале строки');
assert.equal(trackOrigin({ labels: ship, owner, comments: [c(owner, '> Трек: ship — решение владельца', '1')] }).basis, proposal, 'цитата не подтверждение');
// Более поздняя строка владельца отменяет раннюю — по времени, а не по порядку массива.
const later = [c(owner, 'Трек: show — решение владельца', '2026-09-30T09:00:00Z'), c(owner, 'Трек: ship — решение владельца', '2026-09-29T09:00:00Z')];
assert.equal(trackOrigin({ labels: ship, owner, comments: later }).confirmed, false);
assert.equal(trackOrigin({ labels: ['track:show'], owner, comments: later }).confirmed, true);
// Комментарии недоступны — происхождение не установлено.
const unknown = trackOrigin({ labels: ship, owner, comments: null });
assert.equal(unknown.confirmed, false);
assert.equal(unknown.basis, 'метка, происхождение не установлено (комментарии недоступны)');
// Несколько трековых меток — строжайшая и предупреждение.
const two = trackOrigin({ labels: ['track:ship', 'track:ask'], owner, comments: [] });
assert.equal(two.track, 'ask');
assert.match(two.warning, /несколько трековых меток \(track:ask, track:ship\) — дефект разметки, действует строжайшая track:ask/);
assert.equal(trackFromLabels(['track:ship', 'track:show']), 'show');
assert.equal(trackOrigin({ labels: ['small'], owner, comments: [] }).basis, 'прежняя метка small → show (§5.1)');
assert.equal(trackOrigin({ labels: ['small'], owner, comments: [] }).track, 'show');
assert.equal(trackOrigin({ labels: [], infrastructure: true }).basis, 'метки нет: инфраструктура → show');
assert.equal(trackOrigin({ labels: ['bug'] }).basis, 'метки нет: продукт → ask');
});
const RISKY = diffOf([{ path: 'src/pointer-modality.ts', add: [[7, " if (e.pointerType === 'touch') return;"]] }]);
const VISUAL = diffOf([{ path: 'src/styles/plan.styles.ts', add: [[7, ' gap: 4px;']] }]);
const small = { numstat: [{ added: 1, deleted: 0, path: 'src/pointer-modality.ts' }], nameStatus: [{ status: 'M', path: 'src/pointer-modality.ts' }] };
const ownerSays = (track) => [{ author: 'Matysh', body: `Трек: ${track} — решение владельца`, createdAt: '2026-09-30T08:00:00Z' }];
const s7 = (over = {}) => decideTrack({
stage: 'code', branch: 'issue/7-x', labels: ['track:ship', 'S7-code-review'], files: ['src/pointer-modality.ts'],
...small, diff: RISKY, comments: [], owner: 'Matysh', runUrl: 'https://run/1', ...over,
});
test('#707 AC3: решение по ship на S7 — рамки, риск и подтверждение владельца', () => {
// Рамки соблюдены, риск есть, подтверждения нет — show комментарием с классами и способом подтвердить ship.
const raised = s7();
assert.equal(raised.raise, true);
assert.equal(raised.track, 'show');
assert.equal(raised.ship, false);
assert.match(raised.comment, /^\*\*Трек повышен: `track:ship` → `track:show`\.\*\*/);
assert.match(raised.comment, /- touch: src\/pointer-modality\.ts:7 · участок pointer-modality, токен pointerType/);
assert.match(raised.comment, /Понизить трек может только владелец; подтвердить ship — строкой `Трек: ship — решение владельца` в комментарии и снова `S7-code-review`/);
assert.match(raised.comment, /\[Прогон\]\(https:\/\/run\/1\)/);
assert.doesNotMatch(raised.comment, /механические рамки/, 'рамки не нарушены — их не называет');
// Сам комментарий конвейера (он идёт от учётной записи владельца) подтверждением не служит.
assert.equal(trackOrigin({ labels: ['track:ship'], owner: 'Matysh', comments: [{ author: 'Matysh', body: raised.comment, createdAt: '2' }] }).confirmed, false);
// Подтверждено владельцем — ship, строка риска для hp:ship-merge.
const kept = s7({ comments: ownerSays('ship') });
assert.equal(kept.raise, false);
assert.equal(kept.ship, true);
assert.equal(kept.track, 'ship');
assert.equal(kept.note, '', 'модель на ship не зовётся — заметка не нужна');
assert.match(kept.shipRisk, /^Риск по участкам \(трек подтверждён владельцем, не повышен\): touch: src\/pointer-modality\.ts:7/);
assert.match(kept.shipRisk, /\n<!-- hp:ship-risk classes=touch -->$/);
// Риск только visual — ship без повышения.
const visual = s7({ diff: VISUAL, files: ['src/styles/plan.styles.ts'] });
assert.equal(visual.raise, false);
assert.equal(visual.ship, true);
assert.equal(visual.full, false, 'визуальный риск полного набора не заказывает');
// Нарушение рамок — show при любом подтверждении; оба перечня одним комментарием.
const over = { numstat: [{ added: 40, deleted: 0, path: 'src/pointer-modality.ts' }], nameStatus: small.nameStatus };
for (const comments of [[], ownerSays('ship')]) {
const both = s7({ ...over, comments });
assert.equal(both.raise, true);
assert.equal(both.track, 'show');
assert.match(both.comment, /механические рамки ship \(PROCESS\.md §5\): дифф src\/\*\* — 40 строк при рамке 30\./);
assert.match(both.comment, /- touch: src\/pointer-modality\.ts:7/);
assert.match(both.comment, /Понизить трек обратно может только владелец\./);
assert.equal((both.comment.match(/Трек повышен/g) || []).length, 1, 'один комментарий');
}
// Повторный S7 после повышения: метка уже show — второго комментария нет.
assert.equal(s7({ labels: ['track:show', 'S7-code-review'] }).raise, false);
// Этап spec, нет ветки, инфраструктурный дифф — риск пуст, поведение прежнее.
for (const quiet of [s7({ stage: 'spec' }), s7({ branch: '' }), s7({ labels: ['S7-code-review'], files: ['scripts/x.mjs'], diff: diffOf([{ path: 'scripts/x.mjs', add: [[1, 'pointerdown']] }]) })]) {
assert.deepEqual(quiet.risk.classes, []);
assert.equal(quiet.raise, false);
assert.equal(quiet.note, '');
}
assert.equal(s7({ stage: 'spec' }).ship, false, 'ship сливается только на код-ревью');
// Полный набор — только по меткам.
assert.equal(s7({ labels: ['track:show', 'ci:golden'] }).full, true);
});
test('#707 AC5: заметка риска ревьюеру show/ask', () => {
const risk = classifyRisk(RISKY);
const showNote = riskNote({ track: 'show', risk });
assert.match(showNote, /Трек show держится на «решать нечего» \(PROCESS\.md §5\)/);
// #726: вместо Medium «решать есть что — нужен track:ask» — маршрут вердикта.
assert.match(showNote, /не нашёл — `route: reclassify` с названным критерием §5 \(#726\)/);
assert.doesNotMatch(showNote, /Medium/);
assert.match(showNote, /- touch: src\/pointer-modality\.ts:7 · участок pointer-modality, токен pointerType/);
const confirmedNote = riskNote({ track: 'show', confirmed: true, risk });
assert.match(confirmedNote, /трек не повышать: не нашёл — `route: reclassify` с критерием, и конвейер задаст вопрос владельцу, вариант по умолчанию «повысить до ask»/);
assert.doesNotMatch(confirmedNote, /Medium/);
assert.match(riskNote({ track: 'ask', risk }), /Трек ask: сверь, что каждый класс ниже покрыт AC ТЗ/);
const render = classifyRisk(diffOf([{ path: 'src/render/paper-scene.ts', add: [[3, ' const scale = 2;']] }]));
assert.match(riskNote({ track: 'show', risk: render }), /Визуальный риск в пути отрисовки плана без ci:golden — если задача меняет вид, нужен ci:golden/);
assert.doesNotMatch(riskNote({ track: 'show', risk: render, labels: ['ci:golden'] }), /ci:golden —/);
assert.equal(riskNote({ track: 'show', risk: classifyRisk(VISUAL) }), '', 'visual/ui — ни вопроса трека, ни golden');
assert.equal(riskNote({ track: 'show', risk: classifyRisk('') }), '');
const everything = classifyRisk(diffOf(RISK_CLASSES.map((_, i) => ({
path: CARD_FILE,
add: Array.from({ length: 9 }, (__, j) => [i * 100 + j + 1, "on('pointerdown', () => snapToGrid(requestAnimationFrame(callService(migrate(x))))); <path stroke='1'/> customElements.define('x', X)"]),
}))));
const lines = riskNote({ track: 'show', risk: everything }).split('\n');
assert.ok(lines.length <= RISK_NOTE_LINE_LIMIT, `${lines.length} строк`);
assert.equal(RISK_NOTE_LINE_LIMIT, 25);
});
/** Прежний bash guard — эталон, против которого сверяется единое правило (AC6). */
function oldGuardLimit(labels, { branch = true, compareOk = true, files = [] } = {}) {
const has = (label) => labels.includes(label);
let small = has('small') || has('track:show') || has('track:ship');
let trivial = has('trivial');
if (has('track:ask')) { small = false; trivial = false; }
let limit = small || trivial ? 2 : 4;
if (branch && !['track:ship', 'track:show', 'track:ask', 'small', 'trivial'].some(has)
&& compareOk && files.length > 0 && files.length < 300 && files.every((f) => classifyPath(f) !== 'A')) limit = 2;
return limit;
}
test('#707 AC6: трек и лимит — одно правило; прежний guard воспроизведён везде, кроме нескольких трековых меток', () => {
const infra = ['scripts/a.mjs', '.github/workflows/b.yml'];
const product = ['src/a.ts', 'scripts/a.mjs'];
const many = Array.from({ length: 300 }, (_, i) => `scripts/f${i}.mjs`);
const labelSets = [[], ['infra'], ['bug', 'P2'], ['ci:golden'], ['small'], ['trivial'], ['track:ship'], ['track:show'], ['track:ask'],
['track:ask', 'small'], ['track:ask', 'trivial'], ['track:show', 'small'], ['track:ship', 'trivial']];
const multi = [['track:ship', 'track:ask'], ['track:ship', 'track:show'], ['track:show', 'track:ask'], ['track:ship', 'track:show', 'track:ask']];
const worlds = [
{ name: 'инфраструктура', files: infra }, { name: 'продукт', files: product }, { name: 'пусто', files: [] },
{ name: '300 файлов', files: many }, { name: 'compare отказал', files: infra, compareOk: false }, { name: 'нет ветки', files: [], branch: false },
];
for (const labels of [...labelSets, ...multi]) {
for (const world of worlds) {
const files = world.compareOk === false || world.branch === false ? [] : world.files;
const got = guardLimit({ labels, files });
assert.equal(got.limit, cycleLimit(resolveTrack({ labels, files, filesCapped: files.length >= COMPARE_FILES_CAP }).track));
assert.equal(got.limit, oldGuardLimit(labels, world), `${labels.join(',') || 'без меток'} · ${world.name}`);
}
}
for (const labels of multi) assert.equal(guardLimit({ labels, files: infra }).track, explicitTracks(labels)[0], `строжайшая: ${labels}`);
assert.deepEqual(guardLimit({ labels: [], files: many }), { track: 'ask', limit: 4, infrastructure: false }, '300 файлов и больше — ask/4');
assert.deepEqual(guardLimit({ labels: [], files: infra }), { track: 'show', limit: 2, infrastructure: true }, 'инфраструктура без метки — лимит 2');
assert.equal(cycleLimit('ask'), 4);
assert.equal(cycleLimit('show'), 2);
assert.equal(cycleLimit('ship'), 2);
});
test('#707 AC6: guard берёт трек и лимит из process-track.mjs и не держит своей логики трека', async () => {
const { readFileSync } = await import('node:fs');
const workflow = readFileSync(WORKFLOW, 'utf8');
const guard = workflow.slice(workflow.indexOf('\n guard:'), workflow.indexOf('\n prepare:'));
assert.match(guard, /track_out=\$\(node scripts\/process-track\.mjs limit --labels="\$labels" --files="\$changed"\)/);
assert.match(guard, /new_limit=\$\(printf '%s\\n' "\$track_out" \| sed -n 's\/\^limit=\/\/p'\)/);
for (const own of [/SMALL/, /TRIVIAL/, /has track:show/, /has track:ship/, /has track:ask/, /limit=2/, /classify\(/, /files\.length < 300/]) {
assert.doesNotMatch(guard, own, `в guard осталась своя логика трека: ${own}`);
}
});
test('#707: ребейз до ревью — функция и условие шага «Привести ветку к dev» совпадают', async () => {
const { readFileSync } = await import('node:fs');
const workflow = readFileSync(WORKFLOW, 'utf8');
const step = workflow.slice(workflow.indexOf(' - name: Привести ветку к dev\n'), workflow.indexOf(' - name: Зафиксировать SHA материала ревью\n'));
const condition = /\n\s+if ((?:\[ "\$TRACK" = "\w+" \](?: \|\| )?)+); then\n\s+if git merge-tree --write-tree origin\/dev HEAD >\/dev\/null 2>&1; then/.exec(step);
assert.ok(condition, 'условие show/ship перед merge-tree найдено');
const skipOnClean = new Set([...condition[1].matchAll(/"\$TRACK" = "(\w+)"/g)].map((m) => m[1]));
for (const track of ['ship', 'show', 'ask']) {
assert.equal(!skipOnClean.has(track), rebaseBeforeReview(track, true), `${track}: чистое слияние`);
assert.equal(rebaseBeforeReview(track, false), true, `${track}: конфликт — ребейз`);
assert.equal(rebaseBeforeReview(track, null), true, `${track}: чистота не проверена — как в шаге, ребейз`);
}
});
// ---------- #707 AC4: шаг трека, guard и комментарий слияния на настоящем bash ----------
const SCRIPTS_DIR = dirname(SCRIPT);
const CONTEXT = { repository: 'o/r', server_url: 'https://github.com', run_id: '42', repository_owner: 'o' };
/**
* Тело `run: |` шага так, как его прочтёт YAML: блок кончается на первой
* непустой строке с отступом меньше отступа тела (PROCESS.md §10.4 п.4), и
* обрезанный скрипт тест увидит, а не пропустит. Выражения `github.*` —
* подставлены, как это делает раннер.
*/
function stepRun(workflow, marker) {
const start = workflow.indexOf(marker);
assert.ok(start >= 0, `шаг «${marker.trim()}»`);
const lines = workflow.slice(start).split('\n');
const from = lines.findIndex((line) => /^\s+run: \|$/.test(line));
assert.ok(from > 0, 'у шага есть run: |');
const indent = lines[from].indexOf('run:') + 2;
const body = [];
for (const line of lines.slice(from + 1)) {
if (line.trim() && line.length - line.trimStart().length < indent) break;
body.push(line.slice(indent));
}
const text = body.join('\n')
.replace(/\$\{\{ github\.(\w+) \}\}/g, (_, key) => CONTEXT[key])
.replace(/\$\{\{ needs\.guard\.outputs\.cycle \}\}/g, '1');
assert.doesNotMatch(text, /\$\{\{/, 'все выражения подставлены');
return text;
}
const TRACK_STEP = ' - name: "Трек задачи и рамки ship (#696)"\n';
const GUARD_STEP = ' - id: decide\n';
const DECIDE_STEP = ' - name: Решение по вердикту\n';
const PUBLISH_STEP = ' - name: Опубликовать документ ревью\n';
test('#707 AC4: изменённые run шага трека, guard и решения по вердикту проходят bash -n', async (t) => {
if (process.platform === 'win32' || spawnSync('bash', ['--version']).status !== 0) { t.skip('bash недоступен'); return; }
const { readFileSync } = await import('node:fs');
const workflow = readFileSync(WORKFLOW, 'utf8');
// #726: шаг решения и публикация документа тоже изменены.
for (const marker of [TRACK_STEP, GUARD_STEP, DECIDE_STEP, PUBLISH_STEP]) {
const r = spawnSync('bash', ['-n', '-c', stepRun(workflow, marker)], { encoding: 'utf8' });
assert.equal(r.status, 0, `${marker.trim()}: ${r.stderr}`);
assert.equal(r.stderr, '', `${marker.trim()}: bash предупреждает (незакрытый heredoc?): ${r.stderr}`);
}
// Свидетель умеет падать: heredoc без закрывающей строки — предупреждение bash.
const broken = spawnSync('bash', ['-n', '-c', 'cat > /dev/null <<EOF\nтекст\n'], { encoding: 'utf8' });
assert.notEqual(broken.stderr, '');
});
test('#707 AC4: шаг трека в _process.yml — один вызов, комментарии, флаг повышения, риск до промпта и до hp:ship-merge', async () => {
const { readFileSync } = await import('node:fs');
const workflow = readFileSync(WORKFLOW, 'utf8');
const run = stepRun(workflow, TRACK_STEP);
assert.equal((run.match(/process-track\.mjs/g) || []).length, 1, 'один вызов скрипта');
assert.match(run, /gh issue view "\$NUM" --repo "o\/r" --json comments > "\$comments"/, 'JSON комментариев — скрипту');
assert.match(run, /--comments="\$comments" --owner="\$OWNER"/);
const step = workflow.slice(workflow.indexOf(TRACK_STEP), workflow.indexOf(' - name: Привести ветку к dev\n'));
assert.match(step, /OWNER: \$\{\{ github\.repository_owner \}\}/, 'владелец — владелец репозитория');
assert.equal((run.match(/gh issue edit/g) || []).length, 1, 'метки меняются в одном месте');
assert.ok(run.indexOf('gh issue edit') > run.indexOf("grep -qx 'raise=true'"), 'метки — только по флагу повышения');
// Выходы prepare: заметка — в промпт Review, строка риска — в комментарий слияния.
const prepare = workflow.slice(workflow.indexOf('\n prepare:'), workflow.indexOf(' steps:', workflow.indexOf('\n prepare:')));
assert.match(prepare, /risk_note: \$\{\{ steps\.track\.outputs\.risk_note \}\}/);
assert.match(prepare, /ship_risk: \$\{\{ steps\.track\.outputs\.ship_risk \}\}/);
const prompt = workflow.slice(workflow.indexOf(' prompt: |\n'), workflow.indexOf(' claude_args: |'));
assert.match(prompt, /\n\s+\$\{\{ needs\.prepare\.outputs\.risk_note \}\}\n/, 'risk_note доходит до промпта Review');
const decide = workflow.slice(workflow.indexOf(DECIDE_STEP), workflow.indexOf(' - name: dev ушёл вперёд'));
assert.match(decide, /SHIP_RISK: \$\{\{ needs\.prepare\.outputs\.ship_risk \}\}/);
assert.match(decide, /\n\s+\$\{SHIP_RISK\}\n\n\s+<!-- hp:ship-merge material=\$MATERIAL -->\n/, 'строка риска — в комментарии слияния, маркер прежний');
// `full` — только из меток: гейт берёт его у шага трека, скрипт — у resolveTrack.
const gate = workflow.slice(workflow.indexOf(' - name: Validate на материале\n'), workflow.indexOf(' - name: Validate идёт — раунд продолжит событие\n'));
assert.match(gate, /FULL: \$\{\{ steps\.track\.outputs\.full \}\}/);
assert.equal(decideTrack({ stage: 'code', branch: 'b', labels: ['track:show'], diff: RISKY }).full, false, 'риск полного набора не заказывает');
});
/** Замыкание относительных импортов модуля — то, что шаг получит архивом scripts/ из dev. */
function importClosure(entry, seen = new Set()) {
if (seen.has(entry)) return seen;
seen.add(entry);
const text = readFileSync(entry, 'utf8');
for (const m of text.matchAll(/^(?:import|export)[^'"]*from ['"](\.{1,2}\/[^'"]+)['"]/gm)) importClosure(join(dirname(entry), m[1]), seen);
return seen;
}
// Окружение git без GIT_* родителя и без глобального конфига.
const GIT_ENV = {
...Object.fromEntries(Object.entries(process.env).filter(([key]) => !/^GIT_/i.test(key))),
GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t',
GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null',
};
const hasTools = () => process.platform !== 'win32'
&& ['bash', 'tar', 'git'].every((tool) => spawnSync(tool, ['--version']).status === 0);
function parseOutput(text) {
const out = {};
const lines = text.split('\n');
for (let i = 0; i < lines.length; i += 1) {
const heredoc = /^([\w-]+)<<(.+)$/.exec(lines[i]);
if (heredoc) {
const end = lines.indexOf(heredoc[2], i + 1);
assert.ok(end > i, `закрывающий разделитель ${heredoc[2]}`);
out[heredoc[1]] = lines.slice(i + 1, end).join('\n');
i = end;
continue;
}
const kv = /^([\w-]+)=(.*)$/.exec(lines[i]);
if (kv) out[kv[1]] = kv[2];
}
return out;
}
/** Песочница: bare origin с dev (scripts/ и src/), ветка задачи, подменённый gh. */
function trackSandbox(t, { change, base = () => {} }) {
const root = mkdtempSync(join(tmpdir(), 'hp-707-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const git = (cwd, ...args) => {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', env: GIT_ENV });
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
};
const origin = join(root, 'origin.git');
const work = join(root, 'work');
const fake = join(root, 'fake');
const bin = join(root, 'bin');
const temp = join(root, 'runner');
for (const dir of [fake, bin, temp]) mkdirSync(dir);
git(root, 'init', '--bare', '-q', origin);
git(root, 'clone', '-q', origin, work);
git(work, 'checkout', '-q', '-b', 'dev');
mkdirSync(join(work, 'scripts'));
for (const file of importClosure(SCRIPT)) {
assert.ok(file.startsWith(SCRIPTS_DIR), `${file} вне scripts/`);
writeFileSync(join(work, 'scripts', file.slice(SCRIPTS_DIR.length + 1)), readFileSync(file));
}
mkdirSync(join(work, 'src', 'styles'), { recursive: true });
writeFileSync(join(work, 'src', 'pointer-modality.ts'), 'export const a = 1;\nexport const b = 2;\n');
writeFileSync(join(work, 'src', 'styles', 'plan.styles.ts'), 'export const css = `\n .x { color: red; }\n`;\n');
base(work);
git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', 'base');
git(work, 'push', '-q', 'origin', 'dev');
git(work, 'checkout', '-q', '-b', 'issue/7-x');
change(work);
git(work, 'add', '-A'); git(work, 'commit', '-q', '-m', 'task');
git(work, 'push', '-q', 'origin', 'issue/7-x');
git(work, 'checkout', '-q', '--detach', 'issue/7-x');
writeFileSync(join(bin, 'gh'), [
'#!/usr/bin/env bash',
'printf \'%s\\n\' "$*" >> "$FAKE_DIR/gh-calls"',
// #726: `--json labels` — текущие метки для шага решения по вердикту (как их отдал бы --jq).
'if [ "$1 $2" = "issue view" ] && [[ " $* " == *" labels "* ]]; then',
' if [ -f "$FAKE_DIR/labels" ]; then cat "$FAKE_DIR/labels"; exit 0; fi; echo "gh: API недоступен" >&2; exit 1',
'fi',
'case "$1 $2" in',
' "issue view") if [ -f "$FAKE_DIR/comments.json" ]; then cat "$FAKE_DIR/comments.json"; exit 0; fi; echo "gh: API недоступен" >&2; exit 1 ;;',
' "issue comment") while [ $# -gt 0 ]; do if [ "$1" = "--body-file" ]; then cp "$2" "$FAKE_DIR/comment.md"; fi; shift; done ;;',
' "issue edit") ;;',
' *) echo "unexpected gh $*" >&2; exit 97 ;;',
'esac',
'',
].join('\n'), { mode: 0o755 });
const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : '');
return {
work, fake,
run(script, env) {
for (const name of ['gh-calls', 'comment.md']) rmSync(join(fake, name), { force: true });
writeFileSync(join(temp, 'output'), '');
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
cwd: work, encoding: 'utf8',
env: {
...GIT_ENV, PATH: `${bin}:${process.env.PATH}`, RUNNER_TEMP: temp, FAKE_DIR: fake, GH_TOKEN: 'x', NUM: '7',
GITHUB_OUTPUT: join(temp, 'output'), GITHUB_STEP_SUMMARY: join(temp, 'summary'), ...env,
},
});
return {
status: r.status, stdout: r.stdout, stderr: r.stderr,
output: parseOutput(read(join(temp, 'output'))), summary: read(join(temp, 'summary')),
calls: read(join(fake, 'gh-calls')).split('\n').filter(Boolean), comment: read(join(fake, 'comment.md')),
};
},
comments(list) {
if (list === null) rmSync(join(fake, 'comments.json'), { force: true });
else writeFileSync(join(fake, 'comments.json'), JSON.stringify({ comments: list }));
},
labels(list) {
if (list === null) rmSync(join(fake, 'labels'), { force: true });
else writeFileSync(join(fake, 'labels'), `${list.join(',')}\n`);
},
};
}
const touchChange = (work) => writeFileSync(join(work, 'src', 'pointer-modality.ts'),
"export const a = 1;\nexport const b = 2;\nexport const touch = (e) => e.pointerType === 'touch';\n");
const trackEnv = (labels, stage = 'code') => ({ STAGE: stage, LABELS: labels, BRANCH: 'issue/7-x', OWNER: 'Matysh' });
test('#707 AC4: шаг трека на настоящем bash — ship с риском без подтверждения повышается до show', async (t) => {
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const { readFileSync: read } = await import('node:fs');
const box = trackSandbox(t, { change: touchChange });
const run = stepRun(read(WORKFLOW, 'utf8'), TRACK_STEP);
box.comments([{ author: { login: 'claude[bot]' }, body: 'Трек: ship — решение владельца', createdAt: '2026-09-30T08:00:00Z' }]);
const r = box.run(run, trackEnv('track:ship,S7-code-review'));
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /^raise=true$/m);
assert.deepEqual(r.calls, [
'issue view 7 --repo o/r --json comments',
`issue comment 7 --repo o/r --body-file ${join(dirname(box.work), 'runner', 'track', 'raise.md')}`,
'issue edit 7 --repo o/r --add-label track:show --remove-label track:ship',
]);
assert.match(r.comment, /Трек повышен: `track:ship` → `track:show`/);
assert.match(r.comment, /- touch: src\/pointer-modality\.ts:3 · участок pointer-modality, токен pointerType/);
assert.match(r.comment, /\[Прогон\]\(https:\/\/github\.com\/o\/r\/actions\/runs\/42\)/);
assert.equal(r.output.track, 'show');
assert.equal(r.output.ship, 'false');
assert.equal(r.output.mutants, 'false');
assert.equal(r.output.full, 'false');
assert.equal(r.output.risk, 'touch');
assert.match(r.output.risk_note, /Трек show держится на «решать нечего»/);
assert.equal(r.output.ship_risk, undefined);
assert.match(r.summary, /трек \*\*show\*\* · основание: повышен конвейером с ship \(риск touch\); было: метка без подтверждения — предложение · риск по участкам: touch/);
});
test('#707 AC4: шаг трека на настоящем bash — ship, подтверждённый владельцем, остаётся; строка риска доходит до hp:ship-merge', async (t) => {
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const { readFileSync: read } = await import('node:fs');
const workflow = read(WORKFLOW, 'utf8');
const box = trackSandbox(t, { change: touchChange });
box.comments([{ author: { login: 'Matysh' }, body: 'Трек: ship — решение владельца', createdAt: '2026-09-30T08:00:00Z' }]);
const r = box.run(stepRun(workflow, TRACK_STEP), trackEnv('track:ship,S7-code-review'));
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /^raise=false$/m);
assert.deepEqual(r.calls, ['issue view 7 --repo o/r --json comments'], 'ни комментария, ни смены меток');
assert.equal(r.output.track, 'ship');
assert.equal(r.output.ship, 'true');
assert.equal(r.output.risk_note, undefined, 'модель на ship не зовётся');
assert.match(r.output.ship_risk, /^Риск по участкам \(трек подтверждён владельцем, не повышен\): touch: src\/pointer-modality\.ts:3/);
assert.match(r.output.basis, /^метка, подтверждённая владельцем \(2026-09-30\)$/);
// Комментарий слияния ship: шаг «Решение по вердикту» как есть, с этой строкой риска.
const { SHIP_MERGE_MARKER_RE, shipRiskFrom } = await import('../scripts/ship-review.mjs');
const material = 'a'.repeat(40);
const decide = stepRun(workflow, DECIDE_STEP).replaceAll('/tmp/ship-merge.md', join(box.fake, 'ship-merge.md'));
const merged = box.run(decide, { OUT: '', STAGE: 'code', REUSE: 'false', SHIP: 'true', SHIP_RISK: r.output.ship_risk, MATERIAL: material, VALIDATE_URL: 'https://v' });
assert.equal(merged.status, 0, merged.stderr);
assert.equal(merged.output.green, 'true');
assert.equal(SHIP_MERGE_MARKER_RE.exec(merged.comment)?.[1], material, 'маркер слияния по-прежнему находится');
assert.deepEqual(shipRiskFrom([{ body: merged.comment }]), {
classes: ['touch'], line: r.output.ship_risk.split('\n')[0],
});
// Без риска комментарий прежний: маркер есть, строки риска нет.
const plain = box.run(decide, { OUT: '', STAGE: 'code', REUSE: 'false', SHIP: 'true', SHIP_RISK: '', MATERIAL: material, VALIDATE_URL: '' });
assert.equal(SHIP_MERGE_MARKER_RE.exec(plain.comment)?.[1], material);
assert.equal(shipRiskFrom([{ body: plain.comment }]), null);
});
// #755 AC3: дифф вида #741 — удалённый член интерфейса в участке perf — ship не
// повышает; то же тело в классе повышает (свидетель, что риск судится).
const ISO_BASE = (head) => (work) => writeFileSync(join(work, 'src', 'iso-scene-render.ts'),
`export const before = 1;\n\n${head}\n rooms: readonly string[];\n stageSize?: { width: number; height: number } | null;\n}\n`);
const dropStageSize = (work) => {
const file = join(work, 'src', 'iso-scene-render.ts');
writeFileSync(file, readFileSync(file, 'utf8').replace(' stageSize?: { width: number; height: number } | null;\n', ''));
};
test('#755 AC3: шаг трека на настоящем bash — ship с удалённым членом интерфейса не повышается', async (t) => {
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const { readFileSync: read } = await import('node:fs');
const run = stepRun(read(WORKFLOW, 'utf8'), TRACK_STEP);
const bot = [{ author: { login: 'claude[bot]' }, body: 'Трек: ship — решение владельца', createdAt: '2026-10-01T05:00:00Z' }];
const box = trackSandbox(t, { base: ISO_BASE('export interface IsoOverlayFitEnvelopeInput {'), change: dropStageSize });
box.comments(bot);
const r = box.run(run, trackEnv('track:ship,S7-code-review'));
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /^raise=false$/m);
assert.deepEqual(r.calls, ['issue view 7 --repo o/r --json comments'], 'ни комментария повышения, ни смены меток');
assert.equal(r.output.track, 'ship');
assert.equal(r.output.ship, 'true');
assert.equal(r.output.risk, '');
const inClass = trackSandbox(t, { base: ISO_BASE('export class IsoOverlayFitEnvelope {'), change: dropStageSize });
inClass.comments(bot);
const raised = inClass.run(run, trackEnv('track:ship,S7-code-review'));
assert.equal(raised.status, 0, raised.stderr);
assert.match(raised.stdout, /^raise=true$/m);
assert.equal(raised.output.track, 'show');
assert.match(raised.comment, /- perf: src\/iso-scene-render\.ts:5 \(удалена\) · участок iso-scene-render/);
});
test('#707 AC4: шаг трека на настоящем bash — комментарии недоступны, этап spec, show', async (t) => {
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const { readFileSync: read } = await import('node:fs');
const run = stepRun(read(WORKFLOW, 'utf8'), TRACK_STEP);
const box = trackSandbox(t, { change: touchChange });
box.comments(null);
const unknown = box.run(run, trackEnv('track:ship,S7-code-review'));
assert.equal(unknown.status, 0, unknown.stderr);
assert.match(unknown.stdout, /^raise=true$/m, 'без комментариев подтверждения нет');
assert.match(unknown.output.basis, /происхождение не установлено \(комментарии недоступны\)/);
box.comments([]);
const spec = box.run(run, trackEnv('track:ship,S4-spec-review', 'spec'));
assert.equal(spec.status, 0, spec.stderr);
assert.deepEqual({ track: spec.output.track, ship: spec.output.ship, raise: /^raise=true$/m.test(spec.stdout), risk: spec.output.risk },
{ track: 'ship', ship: 'false', raise: false, risk: '' }, 'этап spec — прежнее поведение, риск пуст');
const show = box.run(run, trackEnv('track:show,S7-code-review'));
assert.equal(show.status, 0, show.stderr);
assert.equal(show.output.track, 'show');
assert.equal(show.calls.length, 1, 'show не трогает меток');
assert.match(show.output.risk_note, /- touch: src\/pointer-modality\.ts:3/);
});
// ---------- #726: маршрут вердикта — reclassify, вопрос владельцу, немедленный review-4 ----------
const SHOW = { stage: 'code', track: 'show', limit: 2 };
const pick = (d) => ({ to: d.to, addLabels: d.addLabels, removeLabels: d.removeLabels, kind: d.kind });
test('#726 AC2: таблица маршрутов — каждая строка даёт точные to, метки и kind', () => {
const at = (over) => reviewRoute({ ...SHOW, verdict: 'yellow', high: 0, spent: 0, ...over });
// green, High 0 — как сейчас: вперёд, меток нет.
assert.deepEqual(pick(at({ verdict: 'green' })), { to: 'S8-merged', addLabels: [], removeLabels: [], kind: 'green' });
assert.deepEqual(pick(reviewRoute({ stage: 'spec', track: 'ask', verdict: 'green', high: 0, spent: 0, limit: 4 })),
{ to: 'S5-ready', addLabels: [], removeLabels: [], kind: 'green' });
assert.equal(at({ verdict: 'green', high: 1 }).kind, 'fix', 'зелёный с High — не зелёный');
// code, show, не зелёный, reclassify, критерий из таблицы, не confirmed — ask и S3.
for (const verdict of ['yellow', 'red']) {
assert.deepEqual(pick(at({ verdict, route: 'reclassify', criterion: 'undocumented' })),
{ to: 'S3-spec', addLabels: ['track:ask'], removeLabels: ['track:show'], kind: 'reclassify' }, verdict);
}
for (const id of Object.keys(SHOW_CRITERIA)) assert.equal(at({ route: 'reclassify', criterion: id }).kind, 'reclassify', id);
assert.deepEqual(Object.keys(SHOW_CRITERIA), ['complexity', 'surfaces', 'migration', 'ux-contract', 'perf-touch', 'undocumented']);
assert.equal(at({ route: 'reclassify', criterion: 'migration', verdict: 'red', high: 2 }).kind, 'reclassify', 'High > 0 — маршрут тот же');
// то же, confirmed — трек не меняется, blocked и вопрос владельцу.
const owner = at({ route: 'reclassify', criterion: 'undocumented', confirmed: true });
assert.deepEqual(pick(owner), { to: 'S6-in-progress', addLabels: ['blocked'], removeLabels: [], kind: 'owner-question' });
assert.equal(owner.track, 'show', 'подтверждённый show не повышается');
// не зелёный, fix — как сейчас.
assert.deepEqual(pick(at({ route: 'fix' })), { to: 'S6-in-progress', addLabels: [], removeLabels: [], kind: 'fix' });
assert.deepEqual(pick(reviewRoute({ stage: 'spec', track: 'ask', verdict: 'red', spent: 0, limit: 4 })),
{ to: 'S3-spec', addLabels: [], removeLabels: [], kind: 'fix' });
// reclassify на ask, на spec, без критерия из таблицы — как fix с note.
const ask = reviewRoute({ stage: 'code', track: 'ask', verdict: 'yellow', route: 'reclassify', criterion: 'undocumented', spent: 0, limit: 4 });
assert.deepEqual(pick(ask), { to: 'S6-in-progress', addLabels: [], removeLabels: [], kind: 'fix' });
assert.equal(ask.track, 'ask');
assert.match(ask.note, /^маршрут reclassify не применён: трек `ask`/);
const spec = reviewRoute({ stage: 'spec', track: 'show', verdict: 'yellow', route: 'reclassify', criterion: 'undocumented', spent: 0, limit: 2 });
assert.deepEqual(pick(spec), { to: 'S3-spec', addLabels: [], removeLabels: [], kind: 'fix' });
assert.match(spec.note, /^маршрут reclassify не применён: этап `spec`/);
for (const criterion of ['', undefined, null]) {
const none = at({ route: 'reclassify', criterion });
assert.deepEqual(pick(none), { to: 'S6-in-progress', addLabels: [], removeLabels: [], kind: 'fix' }, String(criterion));
assert.equal(none.note, 'маршрут reclassify не применён: критерий §5 не назван');
}
for (const criterion of ['vibes', 'UNDOCUMENTED', 'constructor', '__proto__', 42, { id: 'undocumented' }]) {
const odd = at({ route: 'reclassify', criterion });
assert.equal(odd.kind, 'fix', JSON.stringify(criterion));
assert.equal(odd.track, 'show', 'трек не меняется');
assert.match(odd.note, /^маршрут reclassify не применён: критерий `[^`]*` не из списка §5 \(complexity, surfaces, migration, ux-contract, perf-touch, undocumented\)$/);
}
assert.equal(at({ route: 'reclassify', criterion: 'vibes' }).note.includes('`vibes`'), true);
// Текущие метки известны: снимается только стоящее, стоящее не ставится второй раз.
const infra = at({ route: 'reclassify', criterion: 'surfaces', labels: ['S7-code-review', 'infra'] });
assert.deepEqual([infra.addLabels, infra.removeLabels], [['track:ask'], []], 'инфраструктура без трековой метки — снимать нечего');
assert.deepEqual(at({ route: 'reclassify', criterion: 'surfaces', confirmed: true, labels: ['track:show', 'blocked'] }).addLabels, [],
'blocked уже стоит — метка одна');
});
test('#726 AC3: вердикт, исчерпавший бюджет, сразу ставит review-4; статус двигается по таблице', () => {
const at = (over) => reviewRoute({ verdict: 'yellow', high: 0, route: 'fix', ...over });
const row = (d) => ({ to: d.to, exhausted: d.exhausted, review4: d.addLabels.includes('review-4'), budget: `${d.spentAfter}/${d.limitAfter}` });
assert.deepEqual(row(at({ ...SHOW, spent: 1 })), { to: 'S6-in-progress', exhausted: true, review4: true, budget: '2/2' }, 'show, spent 1, fix');
assert.deepEqual(row(at({ ...SHOW, spent: 1, route: 'reclassify', criterion: 'undocumented' })),
{ to: 'S3-spec', exhausted: false, review4: false, budget: '2/4' }, 'show, spent 1, reclassify — лимит ask');
assert.deepEqual(row(at({ stage: 'code', track: 'ask', spent: 3, limit: 4 })), { to: 'S6-in-progress', exhausted: true, review4: true, budget: '4/4' });
assert.deepEqual(row(at({ stage: 'spec', track: 'ask', spent: 3, limit: 4 })), { to: 'S3-spec', exhausted: true, review4: true, budget: '4/4' });
assert.deepEqual(row(at({ stage: 'code', track: 'ask', spent: 2, limit: 4 })), { to: 'S6-in-progress', exhausted: false, review4: false, budget: '3/4' });
assert.deepEqual(row(at({ stage: 'code', track: 'ask', spent: 3, limit: 4, verdict: 'green' })),
{ to: 'S8-merged', exhausted: false, review4: false, budget: '3/4' }, 'зелёный цикла не образует');
assert.deepEqual(row(at({ stage: 'code', track: 'ask', spent: 3, limit: 4, verdict: 'red' })).review4, true, 'красный — тоже цикл');
// Вопрос владельцу на show при spent 1 — и вопрос, и review-4: лимит show не меняется.
assert.deepEqual(at({ ...SHOW, spent: 1, route: 'reclassify', criterion: 'undocumented', confirmed: true }).addLabels, ['blocked', 'review-4']);
// Входы guard не прочитаны — значения guard по умолчанию: 0 циклов, лимит трека.
assert.equal(at({ stage: 'code', track: 'show', spent: '', limit: '' }).spentAfter, 1);
assert.equal(at({ stage: 'code', track: 'show', spent: 'x', limit: undefined }).limitAfter, 2);
});
test('#726 AC4: бюджет код-ревью один на все треки — SPEC в счёт не входит, у ask лимит 4, r4 ставит review-4', () => {
const doc = (round, route, criterion, colour = 'жёлтый') => withMaterialAnchors(`# REVIEW-7-r${round}\n\nВердикт: ${colour} · заход r${round} · High: 0 · Medium: 1\n`,
{ sha: 'a'.repeat(40), tree: 'b'.repeat(40), branch: 'issue/7-x', verdict: colour === 'зелёный' ? 'green' : 'yellow', high: 0, route, criterion });
const tree = {
'CODE-REVIEW-7-r1.md': doc(1, 'fix'), // show
'CODE-REVIEW-7-r2.md': doc(2, 'reclassify', 'undocumented'),
// Ревью ТЗ после reclassify — три захода, два блокирующих: свой этап и свой бюджет.
'SPEC-REVIEW-7-r1.md': doc(1, 'fix'),
'SPEC-REVIEW-7-r2.md': doc(2, 'fix'),
'SPEC-REVIEW-7-r3.md': doc(3, 'fix', '', 'зелёный'),
};
// Счёт guard: те же функции, что у `review-doc-guard.mjs --counters`.
const counters = (marker, files) => {
const { rounds } = reviewRoundsFromFiles(Object.keys(files), marker, '7');
return reviewCounters({ rounds, docs: rounds.map((r) => ({ name: `${marker}-7-r${r}.md`, text: files[`${marker}-7-r${r}.md`] })) });
};
// r2 на show: reclassify при одном прошлом цикле — ask, лимит 4, без review-4.
const r2 = reviewRoute({ ...SHOW, verdict: 'yellow', spent: 1, route: 'reclassify', criterion: 'undocumented' });
assert.deepEqual([r2.kind, r2.exhausted, r2.spentAfter, r2.limitAfter], ['reclassify', false, 2, 4]);
// Заход r3 после ревью ТЗ: трек ask, лимит 4, счёт кода — прежние 2.
const code = counters('CODE-REVIEW', tree);
assert.deepEqual([code.spent, code.attempt], [2, 3]);
const { track, limit } = guardLimit({ labels: ['track:ask', 'S7-code-review'], files: ['src/a.ts'] });
assert.deepEqual([track, limit], ['ask', cycleLimit('ask')]);
assert.ok(code.spent < limit, 'guard пускает r3 (`spent -ge limit` ложно)');
assert.deepEqual([counters('SPEC-REVIEW', tree).spent, counters('SPEC-REVIEW', tree).attempt], [2, 4], 'у ревью ТЗ свой бюджет');
assert.deepEqual([counters('CODE-REVIEW', { ...tree, 'SPEC-REVIEW-7-r4.md': doc(4, 'fix') }).spent, counters('CODE-REVIEW', { ...tree, 'SPEC-REVIEW-7-r4.md': doc(4, 'fix') }).attempt], [2, 3],
'SPEC-документы ни бюджет кода не тратят, ни номер захода не сдвигают');
// Жёлтый r3 — без review-4; жёлтый r4 — review-4 сразу.
const r3 = reviewRoute({ stage: 'code', track, verdict: 'yellow', spent: code.spent, limit });
assert.deepEqual([r3.exhausted, r3.spentAfter], [false, 3]);
const after3 = counters('CODE-REVIEW', { ...tree, 'CODE-REVIEW-7-r3.md': doc(3, 'fix') });
assert.deepEqual([after3.spent, after3.attempt], [3, 4]);
const r4 = reviewRoute({ stage: 'code', track, verdict: 'yellow', spent: after3.spent, limit });
assert.deepEqual([r4.exhausted, r4.addLabels, r4.to, r4.spentAfter], [true, ['review-4'], 'S6-in-progress', 4]);
});
test('#726 К3: комментарии маршрута — первая строка, критерий словами, документы путями, бюджет, hp:route', () => {
const docs = [{ name: 'CODE-REVIEW-7-r2.md', text: '' }, { name: 'CODE-REVIEW-7-r1.md', text: '' }];
const raised = routeComment({
decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 1, route: 'reclassify', criterion: 'undocumented' }),
num: '7', cycle: '2', branch: 'issue/7-x', spent: 1, docs, blocking: ['CODE-REVIEW-7-r1.md'], runUrl: 'https://run/1',
});
assert.equal(raised.split('\n')[0], '**Ревью show: решать есть что — трек повышен до `track:ask`.**');
assert.ok(raised.includes(`**${SHOW_CRITERIA.undocumented}** (\`undocumented\`)`), 'критерий словами');
assert.match(raised, /Документы код-ревью ветки `issue\/7-x`:\n- `docs\/reviews\/CODE-REVIEW-7-r1\.md`\n- `docs\/reviews\/CODE-REVIEW-7-r2\.md`\n/);
assert.ok(raised.includes('код остаётся в ветке; полное ТЗ по §7.1 — в теле issue под `## ТЗ`; коммиты класса A — после `S5`'));
assert.match(raised, /Бюджет код-ревью: 2\/4/);
assert.match(raised, /\[Прогон\]\(https:\/\/run\/1\)\.\n\n<!-- hp:route reclassify criterion=undocumented -->\n$/);
assert.doesNotMatch(raised, /Лимит циклов/);
const question = routeComment({
decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 0, route: 'reclassify', criterion: 'perf-touch', confirmed: true }),
num: '7', cycle: '1', branch: 'issue/7-x', spent: 0, docs: [{ name: 'CODE-REVIEW-7-r1.md', text: '' }],
});
assert.equal(question.split('\n')[0], '**Ревью show: решать есть что — вопрос владельцу.**');
assert.match(question, /- \*\*Что неясно:\*\* выполнен ли критерий §5 «нет влияния на производительность и на touch-контракт» \(`perf-touch`\)\. Ревью считает, что нет: вердикт — `docs\/reviews\/CODE-REVIEW-7-r1\.md`/);
assert.match(question, /\n- \*\*Что изменится от ответа:\*\* [^\n]+\n- \*\*Вариант по умолчанию:\*\* повысить до `track:ask`\.\n/, 'одним блоком по §7.1');
assert.match(question, /Как ответить: повысить — метка `track:ask` \(и строка `Трек: ask — решение владельца`\) и снять `blocked`[^;]*; оставить `show` — снять `blocked`, автор чинит по вердикту\./);
assert.match(question, /<!-- hp:route owner-question criterion=perf-touch -->\n$/);
assert.doesNotMatch(question, /review-4|Лимит циклов/);
// Конвейер пишет от учётной записи владельца: его комментарии трек не подтверждают.
for (const body of [raised, question]) {
assert.equal(trackOrigin({ labels: ['track:ask'], owner: 'Matysh', comments: [{ author: 'Matysh', body, createdAt: '2' }] }).confirmed, false);
}
// Исчерпание: прежний префикс первой строкой, счёт, перечень, варианты §4 и четвёртый на show.
const r = (n) => ({ name: `CODE-REVIEW-7-r${n}.md`, text: '' });
const exhausted = routeComment({
decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 1 }), num: '7', cycle: '3', branch: 'issue/7-x', spent: 1,
docs: [r(1), r(2), r(3)], blocking: ['CODE-REVIEW-7-r2.md', 'CODE-REVIEW-7-r3.md'],
});
assert.match(exhausted, /^Лимит циклов ревью исчерпан: блокирующих циклов 2 из 2 на этапе `code` — последний израсходовал этот вердикт \(заход r3\)\. Задача возвращена в `S6-in-progress` и получила `review-4`/);
assert.match(exhausted, /зелёные бюджет не тратят:\n- `docs\/reviews\/CODE-REVIEW-7-r2\.md`\n- `docs\/reviews\/CODE-REVIEW-7-r3\.md` — этот заход\n/, 'зелёный r1 не учтён');
assert.match(exhausted, /1\. \*\*разделить\*\*[^\n]+\n2\. \*\*отклонить\*\*[^\n]+\n3\. \*\*арбитраж владельца\*\*[^\n]+;\n4\. \*\*повысить до `ask`\*\*: лимит станет 4, `review-4` снимает владелец\.\n/);
assert.doesNotMatch(exhausted, /hp:route/);
const onAsk = routeComment({ decision: reviewRoute({ stage: 'code', track: 'ask', verdict: 'red', spent: 3, limit: 4 }), num: '7', cycle: '5', spent: 3, blocking: ['CODE-REVIEW-7-r2.md'] });
assert.match(onAsk, /^Лимит циклов ревью исчерпан: блокирующих циклов 4 из 4/);
assert.match(onAsk, /- `docs\/reviews\/CODE-REVIEW-7-r2\.md`\n- ещё 2 — по комментариям с вердиктом \(страховка счёта, #454\)\n- `docs\/reviews\/CODE-REVIEW-7-r5\.md` — этот заход/,
'счёт по комментариям виден в перечне');
assert.doesNotMatch(onAsk, /повысить до/, 'на ask четвёртого варианта нет');
// Исчерпание с вопросом владельцу — один комментарий, исчерпание первым.
const both = routeComment({ decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 1, route: 'reclassify', criterion: 'surfaces', confirmed: true }), num: '7', cycle: '2', spent: 1 });
assert.match(both, /^Лимит циклов ревью исчерпан[\s\S]+\n\n\*\*Ревью show: решать есть что — вопрос владельцу\.\*\*\n[\s\S]+<!-- hp:route owner-question criterion=surfaces -->\n$/);
// fix с note — строка «маршрут reclassify не применён: <причина>»; без note и зелёный — комментария нет.
const note = routeComment({ decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 0, route: 'reclassify', criterion: 'vibes' }), num: '7', cycle: '1', spent: 0 });
assert.match(note, /^Маршрут reclassify не применён: критерий `vibes` не из списка §5 [^\n]+\. Вердикт возвращает задачу автору как `fix`: `S6-in-progress`\.\n$/);
const noteExhausted = routeComment({ decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 1, route: 'reclassify' }), num: '7', cycle: '2', spent: 1 });
assert.match(noteExhausted, /^Лимит циклов ревью исчерпан[\s\S]+\n\nМаршрут reclassify не применён: критерий §5 не назван\./, 'note — в том же комментарии');
assert.equal(routeComment({ decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 0 }), num: '7', cycle: '1' }), '');
assert.equal(routeComment({ decision: reviewRoute({ ...SHOW, verdict: 'green', spent: 1 }), num: '7', cycle: '2' }), '');
// Недоверенный criterion в текст конвейера не протекает.
const evil = routeComment({ decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 0, route: 'reclassify', criterion: 'q` --> <b>' }), num: '7', cycle: '1' });
assert.match(evil, /критерий `q\?\?--\?\?\?b\?` не из списка/);
assert.doesNotMatch(evil, /q`|-->|<b>/);
// Сводка прогона: маршрут и бюджет после вердикта.
assert.equal(routeSummary({ decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 1, route: 'reclassify', criterion: 'undocumented' }) }),
'- маршрут вердикта **reclassify** (критерий `undocumented`) → `S3-spec` · блокирующих циклов этапа code: 2/4\n');
assert.match(routeSummary({ decision: reviewRoute({ ...SHOW, verdict: 'yellow', spent: 1 }) }), /\*\*fix\*\* → `S6-in-progress` · блокирующих циклов этапа code: 2\/2 · `review-4`/);
});
test('#726 К1: критерии маршрута — пункты «Подсказки аналитику» §5 дословно', () => {
const canon = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'PROCESS.md'), 'utf8');
const norm = (text) => text.replace(/\*\*/g, '').replace(/\s+/g, ' ').toLowerCase();
const section = norm(sectionText(canon, '5-треки-ship-show-ask--метка-владельца'));
for (const [id, text] of Object.entries(SHOW_CRITERIA)) {
assert.ok(section.includes(norm(text)), `${id}: «${text}» нет в PROCESS.md §5`);
assert.ok(section.includes(`\`${id}\``), `${id}: идентификатор не назван в §5`);
}
});
test('#726 AC5: схема вердикта, заметка маршрута в промпте, публикация передаёт маршрут', () => {
const workflow = readFileSync(WORKFLOW, 'utf8');
const schema = JSON.parse(/--json-schema '([^']+)'/.exec(workflow)[1]);
assert.deepEqual(schema.properties.route, { type: 'string', enum: ['fix', 'reclassify'] });
assert.deepEqual(schema.properties.criterion, { type: 'string' });
assert.ok(schema.required.includes('route'), 'route обязателен');
assert.ok(!schema.required.includes('criterion'), 'criterion необязателен');
const prompt = workflow.slice(workflow.indexOf(' prompt: |\n'), workflow.indexOf(' claude_args: |'));
assert.match(prompt, /\n\s+\$\{\{ needs\.prepare\.outputs\.route_note \}\}\n/, 'заметка маршрута доходит до промпта');
const prepare = workflow.slice(workflow.indexOf('\n prepare:'), workflow.indexOf(' steps:', workflow.indexOf('\n prepare:')));
assert.match(prepare, /route_note: \$\{\{ steps\.track\.outputs\.route_note \}\}/);
assert.match(prepare, /confirmed: \$\{\{ steps\.track\.outputs\.confirmed \}\}/);
// Текст заметки: на show/code — поля и список критериев, на прочих — route: fix.
const show = routeNote({ stage: 'code', track: 'show' });
assert.match(show, /`route: reclassify` и `criterion` — если задача не проходит критерий §5 из списка ниже; иначе `route: fix`\. `reclassify` — не зелёный вердикт/);
for (const [id, text] of Object.entries(SHOW_CRITERIA)) assert.ok(show.includes(`\n- \`${id}\` — ${text}`), id);
assert.match(routeNote({ stage: 'code', track: 'show', confirmed: true }), /подтверждён владельцем — конвейер его не повысит, а поставит `blocked`/);
for (const other of [{ stage: 'code', track: 'ask' }, { stage: 'spec', track: 'show' }, { stage: 'spec', track: 'ask' }, { stage: 'code', track: 'ship' }]) {
assert.equal(routeNote(other), '**Маршрут вердикта (#726):** `route: fix`.', JSON.stringify(other));
}
assert.equal(s7({ comments: ownerSays('ship') }).routeNote, '', 'ship в рамках модель не зовёт');
// Публикация: маршрут и критерий — в якорь документа.
const publish = stepRun(workflow, PUBLISH_STEP);
assert.match(publish, /route=\$\(printf '%s' "\$OUT" \| jq -r '\.route \/\/ empty' 2>\/dev\/null \|\| true\)/);
assert.match(publish, /criterion=\$\(printf '%s' "\$OUT" \| jq -r '\.criterion \/\/ empty' 2>\/dev\/null \|\| true\)/);
assert.match(publish, /--verdict="\$verdict" --high="\$high" \\\n\s+--route="\$route" --criterion="\$criterion"\n/);
});
test('#726 AC5: шаг решения — один вызов process-track.mjs route, метки только по его выходу, ship и reuse его не зовут', () => {
const workflow = readFileSync(WORKFLOW, 'utf8');
const run = stepRun(workflow, DECIDE_STEP);
assert.equal((run.match(/process-track\.mjs/g) || []).length, 1, 'один вызов скрипта');
assert.match(run, /route=\$\(node "\$tools\/scripts\/process-track\.mjs" route --stage="\$STAGE" --track="\$TRACK" \\\n\s+--confirmed="\$CONFIRMED" --labels="\$labels" --verdict="\$RUNNER_TEMP\/route-verdict\.json"/);
assert.match(run, /git archive origin\/dev scripts \| tar -x -C "\$tools"/, 'скрипт — из dev');
assert.equal((run.match(/gh issue edit/g) || []).length, 1, 'метки меняются в одном месте');
assert.match(run, /if \[ -n "\$add" \]; then edit\+=\(--add-label "\$add"\); fi\n\s+if \[ -n "\$remove" \]; then edit\+=\(--remove-label "\$remove"\); fi/);
assert.match(run, /add=\$\(field add_labels\); remove=\$\(field remove_labels\); comment=\$\(field comment\)/);
assert.match(run, /--body-file "\$comment"/, 'тело комментария — файл скрипта');
assert.doesNotMatch(run, /--add-label (?:review-4|blocked|track:)/, 'ни одной метки, вписанной в bash');
// Ветки ship и reuse — до вызова скрипта, без него.
const call = run.indexOf('process-track.mjs');
assert.ok(run.indexOf('elif [ "$REUSE" = "true" ]; then') < call && run.indexOf('if [ "$SHIP" = "true" ] && [ "$REUSE" != "true" ]; then') < call);
assert.equal((run.match(/green_forward$/gm) || []).length, 2, 'ship и reuse — прежнее зелёное решение');
const step = workflow.slice(workflow.indexOf(DECIDE_STEP), workflow.indexOf(' - name: dev ушёл вперёд'));
for (const env of ['TRACK: ${{ needs.prepare.outputs.track }}', 'CONFIRMED: ${{ needs.prepare.outputs.confirmed }}',
'SPENT: ${{ needs.guard.outputs.spent }}', 'LIMIT: ${{ needs.guard.outputs.limit }}', 'CYCLE: ${{ needs.guard.outputs.cycle }}',
'BRANCH: ${{ needs.prepare.outputs.branch }}', 'LABELS: ${{ needs.guard.outputs.labels }}']) {
assert.ok(step.includes(` ${env}\n`), env);
}
// Многострочного текста в новой ветке нет: heredoc — только прежний комментарий слияния ship.
assert.equal((run.match(/<<EOF/g) || []).length, 1);
});
const reviewDocText = (round, colour) => `# CODE-REVIEW-7-r${round}\n\nВердикт: ${colour} · заход r${round} · High: 0 · Medium: 1\n`;
/** Ветка материала после публикации: документы ревью этой задачи и чужой SPEC. */
const docsChange = (rounds) => (work) => {
mkdirSync(join(work, 'docs', 'reviews'), { recursive: true });
for (const [round, colour] of rounds) writeFileSync(join(work, 'docs', 'reviews', `CODE-REVIEW-7-r${round}.md`), reviewDocText(round, colour));
writeFileSync(join(work, 'docs', 'reviews', 'SPEC-REVIEW-7-r1.md'), '# SPEC-REVIEW-7-r1\n\nВердикт: жёлтый · High: 0\n');
writeFileSync(join(work, 'docs', 'reviews', 'CODE-REVIEW-70-r1.md'), reviewDocText(1, 'жёлтый'));
};
const verdictOut = (over = {}) => JSON.stringify({ verdict: 'yellow', high: 0, medium: 1, summary: 's', ...over });
const decideEnv = (over = {}) => ({
OUT: verdictOut(), STAGE: 'code', REUSE: 'false', SHIP: 'false', SHIP_RISK: '', MATERIAL: 'a'.repeat(40), VALIDATE_URL: '',
TRACK: 'show', CONFIRMED: 'false', LABELS: 'track:show,S7-code-review', SPENT: '1', LIMIT: '2', CYCLE: '2', BRANCH: 'issue/7-x', ...over,
});
const routeLines = (stdout) => Object.fromEntries(stdout.split('\n').map((line) => /^([a-z_]+)=(.*)$/.exec(line)).filter(Boolean).map((m) => [m[1], m[2]]));
const LABELS_VIEW = 'issue view 7 --repo o/r --json labels --jq [.labels[].name] | join(",")';
test('#726 AC5: шаг решения на настоящем bash — reclassify: ask, S3-spec, комментарий с hp:route и перечнем CODE-REVIEW', async (t) => {
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const box = trackSandbox(t, { change: docsChange([[1, 'жёлтый'], [2, 'жёлтый']]) });
const run = stepRun(readFileSync(WORKFLOW, 'utf8'), DECIDE_STEP);
box.labels(['track:show', 'S7-code-review', 'P2']);
const r = box.run(run, decideEnv({ OUT: verdictOut({ route: 'reclassify', criterion: 'undocumented' }) }));
assert.equal(r.status, 0, r.stderr + r.stdout);
const route = routeLines(r.stdout);
const commentPath = join(dirname(box.work), 'runner', 'route', 'comment.md');
assert.deepEqual(r.calls, [
LABELS_VIEW,
`issue comment 7 --repo o/r --body-file ${commentPath}`,
`issue edit 7 --repo o/r --add-label ${route.add_labels} --remove-label ${route.remove_labels}`,
]);
assert.deepEqual([route.add_labels, route.remove_labels, route.kind], ['track:ask', 'track:show', 'reclassify'], 'метки — по выходу скрипта');
assert.deepEqual({ green: r.output.green, from: r.output.from, to: r.output.to }, { green: 'false', from: 'S7-code-review', to: 'S3-spec' });
assert.equal(r.comment.split('\n')[0], '**Ревью show: решать есть что — трек повышен до `track:ask`.**');
assert.match(r.comment, /- `docs\/reviews\/CODE-REVIEW-7-r1\.md`\n- `docs\/reviews\/CODE-REVIEW-7-r2\.md`\n/);
assert.doesNotMatch(r.comment, /CODE-REVIEW-70|SPEC-REVIEW/, 'только документы код-ревью этой задачи');
assert.match(r.comment, /Бюджет код-ревью: 2\/4/);
assert.match(r.comment, /\[Прогон\]\(https:\/\/github\.com\/o\/r\/actions\/runs\/42\)/);
assert.match(r.comment, /<!-- hp:route reclassify criterion=undocumented -->\n$/);
assert.match(r.summary, /^- маршрут вердикта \*\*reclassify\*\* \(критерий `undocumented`\) → `S3-spec` · блокирующих циклов этапа code: 2\/4$/m);
// Метки не прочитаны — берутся метки guard.
box.labels(null);
const fallback = box.run(run, decideEnv({ OUT: verdictOut({ route: 'reclassify', criterion: 'undocumented' }) }));
assert.equal(fallback.status, 0, fallback.stderr);
assert.equal(fallback.calls.at(-1), 'issue edit 7 --repo o/r --add-label track:ask --remove-label track:show');
});
test('#726 AC5: шаг решения на настоящем bash — исчерпание, вопрос владельцу, fix, зелёный и сбой скрипта', async (t) => {
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const box = trackSandbox(t, { change: docsChange([[1, 'жёлтый'], [2, 'жёлтый']]) });
const run = stepRun(readFileSync(WORKFLOW, 'utf8'), DECIDE_STEP);
box.labels(['track:show', 'S7-code-review']);
const commentPath = join(dirname(box.work), 'runner', 'route', 'comment.md');
// show, spent 1, fix — review-4 этим же вердиктом, возврат в S6.
const exhausted = box.run(run, decideEnv());
assert.equal(exhausted.status, 0, exhausted.stderr + exhausted.stdout);
assert.deepEqual(exhausted.calls, [LABELS_VIEW, `issue comment 7 --repo o/r --body-file ${commentPath}`, 'issue edit 7 --repo o/r --add-label review-4']);
assert.match(exhausted.comment, /^Лимит циклов ревью исчерпан: блокирующих циклов 2 из 2 на этапе `code`/);
assert.match(exhausted.comment, /- `docs\/reviews\/CODE-REVIEW-7-r1\.md`\n- `docs\/reviews\/CODE-REVIEW-7-r2\.md` — этот заход\n/);
assert.equal(exhausted.output.to, 'S6-in-progress');
assert.match(exhausted.summary, /· `review-4`$/m);
// Подтверждённый show: blocked и вопрос владельцу; blocked уже стоит — метка не ставится второй раз.
box.comments([]);
const question = box.run(run, decideEnv({ CONFIRMED: 'true', SPENT: '0', CYCLE: '1', OUT: verdictOut({ route: 'reclassify', criterion: 'surfaces' }) }));
assert.equal(question.status, 0, question.stderr);
assert.deepEqual(question.calls.slice(1), [`issue comment 7 --repo o/r --body-file ${commentPath}`, 'issue edit 7 --repo o/r --add-label blocked']);
assert.equal(question.comment.split('\n')[0], '**Ревью show: решать есть что — вопрос владельцу.**');
assert.equal(question.output.to, 'S6-in-progress');
box.labels(['track:show', 'S7-code-review', 'blocked']);
const again = box.run(run, decideEnv({ CONFIRMED: 'true', SPENT: '0', CYCLE: '1', OUT: verdictOut({ route: 'reclassify', criterion: 'surfaces' }) }));
assert.deepEqual(again.calls.slice(1), [`issue comment 7 --repo o/r --body-file ${commentPath}`], 'комментарий один, меток не трогает');
// Обычный fix без исчерпания — ни комментария, ни меток; статус — S6.
box.labels(['track:show', 'S7-code-review']);
const fix = box.run(run, decideEnv({ SPENT: '0', CYCLE: '1' }));
assert.equal(fix.status, 0, fix.stderr);
assert.deepEqual(fix.calls, [LABELS_VIEW]);
assert.deepEqual({ green: fix.output.green, to: fix.output.to }, { green: 'false', to: 'S6-in-progress' });
// Зелёный — вперёд, как раньше.
const green = box.run(run, decideEnv({ SPENT: '1', OUT: verdictOut({ verdict: 'green', medium: 0, route: 'fix' }) }));
assert.equal(green.status, 0, green.stderr);
assert.deepEqual(green.calls, [LABELS_VIEW]);
assert.deepEqual({ green: green.output.green, from: green.output.from, to: green.output.to }, { green: 'true', from: 'S7-code-review', to: 'S8-merged' });
// Противоречивый вердикт — шаг падает до комментария и меток: метка не меняется, зовётся владелец.
const broken = box.run(run, decideEnv({ OUT: verdictOut({ verdict: 'green', route: 'reclassify', criterion: 'undocumented' }) }));
assert.notEqual(broken.status, 0);
assert.deepEqual(broken.calls, [LABELS_VIEW]);
assert.equal(broken.output.green, undefined, 'исхода нет');
assert.match(broken.stderr, /reclassify при зелёном вердикте/);
});
test('#726 AC5: шаг трека на настоящем bash — confirmed и заметка маршрута для промпта', async (t) => {
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const box = trackSandbox(t, { change: touchChange });
const run = stepRun(readFileSync(WORKFLOW, 'utf8'), TRACK_STEP);
box.comments([]);
const show = box.run(run, trackEnv('track:show,S7-code-review'));
assert.equal(show.status, 0, show.stderr);
assert.equal(show.output.confirmed, 'false');
assert.equal(show.output.route_note, routeNote({ stage: 'code', track: 'show' }));
box.comments([{ author: { login: 'Matysh' }, body: 'Трек: show — решение владельца', createdAt: '2026-09-30T08:00:00Z' }]);
const confirmed = box.run(run, trackEnv('track:show,S7-code-review'));
assert.equal(confirmed.output.confirmed, 'true');
assert.match(confirmed.output.route_note, /поставит `blocked`/);
const ask = box.run(run, trackEnv('track:ask,S7-code-review'));
assert.equal(ask.output.route_note, '**Маршрут вердикта (#726):** `route: fix`.');
const spec = box.run(run, trackEnv('track:show,S4-spec-review', 'spec'));
assert.equal(spec.output.route_note, '**Маршрут вердикта (#726):** `route: fix`.');
});