Files
houseplan-card/test/process-track.test.mjs
T
Claudeandclaude[bot] 1d51beade1 feat(process): risk by changed hunks decides ship and informs show (#707)
The ship limits count lines and files but not what was touched: a
12-line pointerdown handler passed them like a typo and merged unread.
The track rule also lived twice - the guard computed the cycle limit in
bash while process-track.mjs computed the track, and the two disagreed
on multiple track labels. The packet still told authors to rebase
show/ship branches that merge cleanly.

- scripts/change-risk.mjs: one pure classifier over `git diff -U0` from
  the merge base. Class A lines only; comments, blank lines and pure
  renames give no risk; deletions do. Area and token rules per class
  (geometry, touch, migration, devices, perf, ux, visual render/ui),
  evidence as path:line, five per class.
- process-track.mjs: owner confirmation is a comment line
  "Трек: <x> — решение владельца" by the repo owner (latest wins, only
  for the current track); several track labels read as the strictest
  with a warning; cycleLimit, guardLimit and rebaseBeforeReview are the
  single source. `stage` makes the whole S7 track decision in one call:
  ship with risk and no confirmation is raised to show with evidence,
  a confirmed ship keeps merging without the model and records the risk
  for the batch review; show/ask get a risk note for the reviewer.
- _process.yml: the guard asks process-track.mjs for the limit and keeps
  no track logic; the track step calls the script once and only
  executes its raise flag and comment file; risk_note reaches the
  Review prompt, ship_risk reaches the hp:ship-merge comment (marker
  line unchanged).
- task-packet.mjs: track basis, limit and rebase policy; next step
  without the stale rebase line; risk with its consequence per track;
  required checks with reasons (ci:golden only on render risk);
  changelog and visual evidence - from the same exports.
- ship-review.mjs: the batch brief prints the risk line of a ship merge.
- Canon: PROCESS.md §5, §5.1, §10.4, §11.7, both digests, AGENTS.md.
- Registry anchors that watched the moved code are moved, not dropped.

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

750 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
COMPARE_FILES_CAP, RISK_CLASSES, RISK_NOTE_LINE_LIMIT, SHIP_SRC_LINE_LIMIT, classifyRisk, cycleLimit, decideTrack, explicitTracks,
guardLimit, hasTrackLabel, parseNameStatus, parseNumstat, rebaseBeforeReview, resolveTrack, riskClassLine, riskNote,
shipLimitViolations, trackFromLabels, trackOrigin,
} from '../scripts/process-track.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$/);
});
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\)/);
assert.match(showNote, /не нашёл — Medium «решать есть что — нужен track:ask» с названным критерием §5/);
assert.match(showNote, /- touch: src\/pointer-modality\.ts:7 · участок pointer-modality, токен pointerType/);
const confirmedNote = riskNote({ track: 'show', confirmed: true, risk });
assert.match(confirmedNote, /трек не повышать: не нашёл — вопрос владельцу в комментарии, вариант по умолчанию «повысить до 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';
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');
for (const marker of [TRACK_STEP, GUARD_STEP, DECIDE_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 }) {
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');
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"',
'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 }));
},
};
}
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);
});
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/);
});