mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-01 20:29:00 +00:00
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
1238 lines
101 KiB
JavaScript
1238 lines
101 KiB
JavaScript
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`.');
|
||
});
|