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