mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-05 06:08:59 +00:00
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
This commit is contained in:
@@ -48,6 +48,10 @@ const KEY_RULES = {
|
||||
['3-правила', 'Статус меняется до действия, а не после'],
|
||||
['5-треки-ship-show-ask--метка-владельца', 'Метка владельца главнее критериев'],
|
||||
['5-треки-ship-show-ask--метка-владельца', 'ожидаемое поведение уже зафиксировано'],
|
||||
// #707: трек подтверждает строка владельца, метка без неё — предложение.
|
||||
['5-треки-ship-show-ask--метка-владельца', 'строкой `Трек: <ship|show|ask> — решение владельца` в начале строки'],
|
||||
['5-треки-ship-show-ask--метка-владельца', 'Метка без такой строки — предложение'],
|
||||
['5-треки-ship-show-ask--метка-владельца', 'Агент эту строку не пишет никогда'],
|
||||
['71-цепочка', 'Владельцу задаются только продуктовые вопросы'],
|
||||
['71-цепочка', 'issue остаётся в `S3-spec` и получает `blocked`'],
|
||||
['26-в-разработке--реализация', 'каждый коммит с файлами классов A, B или D несёт трейлеры `Issue: #<NN>` и `User-Visible: yes|no`'],
|
||||
|
||||
+570
-6
@@ -1,13 +1,18 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
|
||||
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 {
|
||||
SHIP_SRC_LINE_LIMIT, hasTrackLabel, parseNameStatus, parseNumstat, resolveTrack, shipLimitViolations, trackFromLabels,
|
||||
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 —
|
||||
@@ -131,10 +136,13 @@ test('конвейер: трек снимается до ребейза, мут
|
||||
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 не ребейзится');
|
||||
assert.match(trackStep, /node "\$tools\/scripts\/process-track\.mjs" resolve --labels="\$LABELS" --base=origin\/dev/);
|
||||
assert.match(trackStep, /node "\$tools\/scripts\/process-track\.mjs" ship-limits --base=origin\/dev --head=HEAD/);
|
||||
assert.match(trackStep, /grep -qx 'ship=true'; then\n\s+ship=true/, '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/,
|
||||
@@ -181,5 +189,561 @@ test('#697: конвейер передаёт полный набор гейту
|
||||
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\}"/);
|
||||
assert.match(workflow, /full=\$\(printf '%s\\n' "\$out" \| sed -n 's\/\^full=\/\/p'\)/);
|
||||
// #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/);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
ANCHOR_MARKER, REVIEW_DOC_ALLOWLIST, anchorLiveness, REVIEW_HEADER_LINES, citedMaterialShas, danglingMaterialRefusal, materialAnchorBlock, materialAnchorsFrom, parseSpecList, pathsOutsideAllowlist, reviewDocPushRefusal, withMaterialAnchors,
|
||||
@@ -970,14 +974,80 @@ test('guard перечисляет docs/reviews деревом, а не contents
|
||||
assert.match(guard, /contents\/docs\/reviews\/\$name\?ref=\$target/);
|
||||
});
|
||||
|
||||
test('r1 #695: guard даёт инфраструктуре без трековой метки лимит show (§5.1)', () => {
|
||||
/**
|
||||
* Шаг `decide` job guard как есть — настоящим bash (#707). Подменён только
|
||||
* `gh`: метки, ветка задачи, compare API; счётчик раундов и трек считают
|
||||
* настоящие `review-doc-guard.mjs` и `process-track.mjs` из рабочей копии.
|
||||
*/
|
||||
function runGuard(t, { labels, compare = null, branch = true }) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'hp-guard-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const bin = join(root, 'bin');
|
||||
mkdirSync(bin);
|
||||
writeFileSync(join(root, 'labels'), `${labels.join('\n')}\n`);
|
||||
if (compare) writeFileSync(join(root, 'compare'), `${compare.join('\n')}\n`);
|
||||
writeFileSync(join(bin, 'gh'), [
|
||||
'#!/usr/bin/env bash',
|
||||
'printf \'%s\\n\' "$*" >> "$FAKE_DIR/calls"',
|
||||
'case "$*" in',
|
||||
' "issue view 7 --repo o/r --json labels --jq .labels[].name") cat "$FAKE_DIR/labels" ;;',
|
||||
' "issue view 7 --repo o/r --json comments") echo \'{"comments":[]}\' ;;',
|
||||
` "api repos/o/r/git/matching-refs/heads/issue/7- --jq .[].ref") ${branch ? "echo refs/heads/issue/7-x" : ':'} ;;`,
|
||||
' "api repos/o/r/commits/issue/7-x --jq .commit.committer.date") echo 2026-10-01T00:00:00Z ;;',
|
||||
' "api repos/o/r/compare/dev...issue/7-x --jq .files[].filename") [ -f "$FAKE_DIR/compare" ] || exit 1; cat "$FAKE_DIR/compare" ;;',
|
||||
' "issue comment"*|"issue edit"*) ;;',
|
||||
' *) exit 1 ;;',
|
||||
'esac',
|
||||
'',
|
||||
].join('\n'), { mode: 0o755 });
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
const lines = workflow.slice(workflow.indexOf(' - id: decide\n')).split('\n');
|
||||
const from = lines.indexOf(' run: |');
|
||||
const body = [];
|
||||
for (const line of lines.slice(from + 1)) {
|
||||
if (line.trim() && !/^ {10}/.test(line)) break;
|
||||
body.push(line.replace(/^ {10}/, ''));
|
||||
}
|
||||
const context = { repository: 'o/r', server_url: 'https://github.com', run_id: '42' };
|
||||
const script = body.join('\n').replace(/\$\{\{ github\.(\w+) \}\}/g, (_, key) => context[key]);
|
||||
const output = join(root, 'output');
|
||||
writeFileSync(output, '');
|
||||
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
|
||||
cwd: fileURLToPath(new URL('..', import.meta.url)), encoding: 'utf8',
|
||||
env: {
|
||||
...process.env, PATH: `${bin}:${process.env.PATH}`, FAKE_DIR: root, GITHUB_OUTPUT: output,
|
||||
GITHUB_STEP_SUMMARY: join(root, 'summary'), GH_TOKEN: 'x', REPO: 'o/r', LABEL: 'S7-code-review', NUM: '7',
|
||||
},
|
||||
});
|
||||
const out = Object.fromEntries(readFileSync(output, 'utf8').split('\n').filter((l) => /^\w+=/.test(l)).map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]));
|
||||
return { status: r.status, stdout: r.stdout, stderr: r.stderr, out };
|
||||
}
|
||||
|
||||
test('r1 #695: guard даёт инфраструктуре без трековой метки лимит show (§5.1)', (t) => {
|
||||
if (process.platform === 'win32' || spawnSync('bash', ['--version']).status !== 0) { t.skip('bash недоступен'); return; }
|
||||
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
|
||||
const guard = workflow.slice(workflow.indexOf('\n guard:'), workflow.indexOf('\n prepare:'));
|
||||
assert.match(guard, /gh api "repos\/\$REPO\/compare\/dev\.\.\.\$branch" --jq '\.files\[\]\.filename'/,
|
||||
'признак инфраструктуры — пути диффа ветки против dev');
|
||||
assert.match(guard, /files\.every\(\(f\) => classify\(f\) !== "A"\)/, 'механический признак §1: ни одного файла класса A');
|
||||
assert.match(guard, /files\.length < 300/, 'обрезанный ответ compare инфраструктуру не доказывает');
|
||||
assert.match(guard, /; then\n\s+limit=2\n\s+echo "инфраструктурная задача без трековой метки/);
|
||||
assert.match(guard, /! has track:ship && ! has track:show && ! has track:ask \\\n\s+&& ! has small && ! has trivial; then/,
|
||||
'явная метка трека главнее признака инфраструктуры');
|
||||
// #707: трек и лимит — из process-track.mjs; своей логики трека в guard нет.
|
||||
assert.match(guard, /gh api "repos\/\$REPO\/compare\/dev\.\.\.\$branch" --jq '\.files\[\]\.filename' > "\$changed" 2>\/dev\/null \\\n\s+\|\| : > "\$changed"/,
|
||||
'признак инфраструктуры — пути диффа ветки против dev; отказ API — пустой список');
|
||||
assert.match(guard, /node scripts\/process-track\.mjs limit --labels="\$labels" --files="\$changed"/);
|
||||
assert.doesNotMatch(guard, /limit=2|SMALL|TRIVIAL/, 'лимит 2 guard сам не ставит');
|
||||
const infra = ['scripts/x.mjs', '.github/workflows/y.yml'];
|
||||
const cases = [
|
||||
[{ labels: ['S7-code-review'], compare: infra }, 2, 'инфраструктура без трековой метки — show'],
|
||||
[{ labels: ['S7-code-review', 'track:ask'], compare: infra }, 4, 'явная метка главнее признака инфраструктуры'],
|
||||
[{ labels: ['S7-code-review'], compare: ['src/a.ts', ...infra] }, 4, 'продукт — ask'],
|
||||
[{ labels: ['S7-code-review'], compare: null }, 4, 'compare отказал — инфраструктура не доказана'],
|
||||
[{ labels: ['S7-code-review'], compare: Array.from({ length: 300 }, (_, i) => `scripts/f${i}.mjs`) }, 4, 'обрезанный ответ compare инфраструктуру не доказывает'],
|
||||
[{ labels: ['S7-code-review'], branch: false }, 4, 'ветки нет'],
|
||||
[{ labels: ['S7-code-review', 'small'], compare: ['src/a.ts'] }, 2, 'прежняя метка small — show'],
|
||||
[{ labels: ['S7-code-review', 'track:ship', 'track:ask'], compare: ['src/a.ts'] }, 4, 'несколько трековых меток — строжайшая'],
|
||||
];
|
||||
for (const [input, limit, why] of cases) {
|
||||
const r = runGuard(t, input);
|
||||
assert.equal(r.status, 0, `${why}: ${r.stderr}`);
|
||||
assert.equal(r.out.stage, 'code', why);
|
||||
assert.equal(r.out.limit, String(limit), why);
|
||||
assert.equal(r.out.labels, input.labels.join(','), `${why}: метки для prepare`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@ import test from 'node:test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
anchorBlock, isShipIssue, parseAnchorBlock, renderShipBrief, shipIssuesInRange, shipReviewDocPath,
|
||||
shipReviewProblems, specSection,
|
||||
SHIP_MERGE_MARKER_RE, anchorBlock, isShipIssue, parseAnchorBlock, renderShipBrief, shipIssuesInRange, shipReviewDocPath,
|
||||
shipReviewProblems, shipRiskFrom, specSection,
|
||||
} from '../scripts/ship-review.mjs';
|
||||
import { parseDocName, renderIndex } from '../scripts/reviews-index.mjs';
|
||||
import { archivePlan } from '../scripts/reviews-archive.mjs';
|
||||
@@ -116,3 +116,25 @@ test('#696 оба пути публикации беты проверяют па
|
||||
assert.ok(check > 0, 'локальная публикация зовёт тот же гейт');
|
||||
assert.ok(check < main.indexOf('if (checkOnly) return;'), 'и в режиме --check тоже');
|
||||
});
|
||||
|
||||
test('#707 AC12: бриф ship печатает строку риска из комментария hp:ship-merge, если она есть', async () => {
|
||||
const { classifyRisk, shipRiskText } = await import('../scripts/process-track.mjs');
|
||||
const risk = classifyRisk("diff --git a/src/pointer-modality.ts b/src/pointer-modality.ts\n--- a/src/pointer-modality.ts\n+++ b/src/pointer-modality.ts\n@@ -2,0 +3 @@\n+ if (e.pointerType === 'touch') return;\n");
|
||||
const line = shipRiskText({ risk, confirmed: true });
|
||||
const merge = `**Слияние без ревью модели: трек ship.** …\n\n${line}\n\n${MARKER}\n`;
|
||||
assert.equal(SHIP_MERGE_MARKER_RE.exec(merge)?.[1], sha('a'), 'маркер слияния находится и с новой строкой');
|
||||
assert.deepEqual(shipRiskFrom([{ body: 'обсуждение' }, { body: merge }]), { classes: ['touch'], line: line.split('\n')[0] });
|
||||
assert.equal(shipRiskFrom([{ body: `Слияние без ревью модели\n\n${MARKER}` }]), null, 'комментарий до #707 — риск не записан');
|
||||
assert.equal(shipRiskFrom([{ body: '<!-- hp:ship-risk classes=touch -->' }]), null, 'строка риска вне комментария слияния не считается');
|
||||
const commits = [{ sha: sha('c'), message: 'fix: x\n\nIssue: #701\nUser-Visible: no' }, { sha: sha('d'), message: 'fix: y\n\nIssue: #702\nUser-Visible: no' }];
|
||||
const data = {
|
||||
701: { title: 'С риском', body: '## ТЗ\n\nстрока', labels: [], comments: [{ body: merge }] },
|
||||
702: { title: 'Без риска', body: '## ТЗ\n\nстрока', labels: [], comments: [{ body: MARKER }] },
|
||||
};
|
||||
const ship = shipIssuesInRange({ commits, issueData: (n) => data[n] });
|
||||
const brief = renderShipBrief({ tag: 'v1.80.0-beta.1', candidate: sha('e'), base: null, ship });
|
||||
const section = (n) => brief.split('\n### ').find((part) => part.startsWith(`#${n} `)) ?? '';
|
||||
assert.match(section(701), /Риск по участкам \(трек подтверждён владельцем, не повышен\): touch: src\/pointer-modality\.ts:3 · участок pointer-modality, токен pointerType/);
|
||||
assert.doesNotMatch(section(702), /Риск по участкам/);
|
||||
assert.equal(ship.find((i) => i.number === 702).risk, undefined);
|
||||
});
|
||||
|
||||
+162
-1
@@ -1,8 +1,13 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import * as packet from '../scripts/task-packet.mjs';
|
||||
import {
|
||||
branchIsInfrastructure, buildPacket, evidenceFor, productFlowEvidence, extractAcceptanceCriteria, lastVerdict, ownerDecisions, renderPacket, rightsFor,
|
||||
trackFromLabels, hasTrackLabel,
|
||||
trackFromLabels, hasTrackLabel, readMergeState,
|
||||
} from '../scripts/task-packet.mjs';
|
||||
import { materialAnchorBlock } from '../scripts/review-doc-guard.mjs';
|
||||
|
||||
@@ -241,3 +246,159 @@ test('r1 #695: инфраструктурная задача с явной ме
|
||||
assert.equal(hasTrackLabel(['bug', 'infra']), false);
|
||||
assert.equal(hasTrackLabel(['small']), true);
|
||||
});
|
||||
|
||||
// ---------- #707: трек, следующий шаг, риск, проверки, changelog ----------
|
||||
|
||||
const DIFF = (path, text, line = 3) => `diff --git a/${path} b/${path}\n--- a/${path}\n+++ b/${path}\n@@ -${line - 1},0 +${line} @@\n+${text}\n`;
|
||||
const branchWith = (over = {}) => ({
|
||||
name: 'issue/707-x', tip: 'e'.repeat(40), base: 'f'.repeat(40), ahead: 1, behind: 0, treeWithoutReviews: null, infrastructure: false,
|
||||
changedFiles: [], diff: '', commits: [], smokes: null, mergeClean: true, conflicts: [], ...over,
|
||||
});
|
||||
const packetOf = ({ labels = ['S6-in-progress'], comments = [], branch = branchWith(), body = '## ТЗ\n- AC1: x\n' } = {}) => buildPacket({
|
||||
issue: { number: 707, title: 'risk', state: 'OPEN', url: 'u', body }, labels, comments, owner: 'Matysh', branch,
|
||||
});
|
||||
|
||||
test('#707 AC6: пакет берёт трек, основание, лимит, риск и ребейз из тех же функций, что конвейер', async () => {
|
||||
const track = await import('../scripts/process-track.mjs');
|
||||
for (const name of ['trackOrigin', 'cycleLimit', 'rebaseBeforeReview', 'classifyRisk', 'trackFromLabels', 'hasTrackLabel']) {
|
||||
assert.equal(packet[name], track[name], name);
|
||||
}
|
||||
});
|
||||
|
||||
test('#707 AC7: пакет — раздел «Трек»: четыре основания, лимит и ребейз в markdown и --json', () => {
|
||||
const owner = [{ author: 'Matysh', body: 'Трек: show — решение владельца', createdAt: '2026-09-30T08:00:00Z' }];
|
||||
const variants = [
|
||||
[{ labels: ['S6-in-progress', 'track:show'], comments: owner }, 'show', 'метка, подтверждённая владельцем (2026-09-30)', 2, false],
|
||||
[{ labels: ['S6-in-progress', 'track:ship'] }, 'ship', 'метка без подтверждения — предложение', 2, false],
|
||||
[{ labels: ['S6-in-progress', 'small'] }, 'show', 'прежняя метка small → show (§5.1)', 2, false],
|
||||
[{ labels: ['S6-in-progress'] }, 'ask', 'метки нет: продукт → ask', 4, true],
|
||||
];
|
||||
for (const [input, track, basis, limit, rebase] of variants) {
|
||||
const p = packetOf(input);
|
||||
assert.deepEqual(
|
||||
{ track: p.trackDetail.track, basis: p.trackDetail.basis, limit: p.trackDetail.limit, rebase: p.trackDetail.rebaseBeforeReview },
|
||||
{ track, basis, limit, rebase }, basis,
|
||||
);
|
||||
const md = renderPacket(p);
|
||||
assert.match(md, new RegExp(`## Трек\\n- ${track} · основание: ${basis.replace(/[()]/g, '\\$&')}\\n- лимит циклов код-ревью: ${limit} · ребейз до ревью: ${rebase ? 'да' : 'нет'}\\n`));
|
||||
assert.equal(JSON.parse(JSON.stringify(p)).trackDetail.basis, basis, '--json несёт те же поля');
|
||||
}
|
||||
const infra = packetOf({ labels: ['infra'], branch: branchWith({ infrastructure: true }), body: '' });
|
||||
assert.equal(infra.trackDetail.basis, 'метки нет: инфраструктура → show');
|
||||
const two = renderPacket(packetOf({ labels: ['S6-in-progress', 'track:ship', 'track:ask'] }));
|
||||
assert.match(two, /- внимание: несколько трековых меток \(track:ask, track:ship\) — дефект разметки, действует строжайшая track:ask/);
|
||||
// Без ветки политика show/ship зависит от чистоты слияния — пакет её не выдумывает.
|
||||
assert.equal(packetOf({ labels: ['track:show'], branch: null }).trackDetail.rebaseBeforeReview, null);
|
||||
assert.match(renderPacket(packetOf({ labels: ['track:show'], branch: null })), /ребейз до ревью: только при конфликте с dev/);
|
||||
});
|
||||
|
||||
test('#707 AC8: следующий шаг — ребейз только там, где он нужен', () => {
|
||||
const at = (labels, over) => renderPacket(packetOf({ labels: ['S6-in-progress', ...labels], branch: branchWith(over) }));
|
||||
assert.doesNotMatch(at(['track:show'], { behind: 0 }), /## Следующий шаг/, 'позади 0 — про ребейз ничего');
|
||||
const askClean = at(['track:ask'], { behind: 3, mergeClean: true });
|
||||
assert.match(askClean, /## Следующий шаг\n- позади dev на 3, слияние чистое — конвейер сам приведёт ветку к dev до ревью/);
|
||||
const showClean = at(['track:show'], { behind: 3, mergeClean: true });
|
||||
assert.match(showClean, /- позади dev на 3, слияние чистое — ребейз не нужен: один раз при слиянии/);
|
||||
for (const md of [askClean, showClean]) assert.doesNotMatch(md, /перед S7 ребейз/);
|
||||
assert.match(at(['track:show'], { behind: 2, mergeClean: false, conflicts: ['src/a.ts', 'docs/b.md'] }),
|
||||
/- слияние с dev конфликтует: src\/a\.ts, docs\/b\.md — ребейз до S7 \(`node scripts\/rebase-on-dev\.mjs`\)/);
|
||||
assert.match(at(['track:show'], { behind: 2, mergeClean: null }), /- позади dev на 2: чистота слияния не проверена/);
|
||||
});
|
||||
|
||||
test('#707 AC8: чистота слияния — настоящий merge-tree во временном репозитории и отказ merge-tree', (t) => {
|
||||
if (spawnSync('git', ['--version']).status !== 0) { t.skip('git недоступен'); return; }
|
||||
const dir = mkdtempSync(join(tmpdir(), 'hp-packet-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const env = { ...Object.fromEntries(Object.entries(process.env).filter(([k]) => !/^GIT_/i.test(k))), GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_NOSYSTEM: '1' };
|
||||
const git = (...args) => {
|
||||
const r = spawnSync('git', ['-c', 'user.name=t', '-c', 'user.email=t@t', ...args], { cwd: dir, encoding: 'utf8', env });
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
return r.stdout.trim();
|
||||
};
|
||||
git('init', '-q', '-b', 'dev');
|
||||
writeFileSync(join(dir, 'a.txt'), 'base\n'); writeFileSync(join(dir, 'b.txt'), 'base\n');
|
||||
git('add', '.'); git('commit', '-q', '-m', 'base');
|
||||
git('checkout', '-q', '-b', 'task');
|
||||
writeFileSync(join(dir, 'a.txt'), 'task\n');
|
||||
git('commit', '-q', '-am', 'task');
|
||||
git('checkout', '-q', 'dev');
|
||||
writeFileSync(join(dir, 'b.txt'), 'dev\n');
|
||||
git('commit', '-q', '-am', 'dev moves');
|
||||
assert.deepEqual(readMergeState({ cwd: dir, onto: 'dev', ref: 'task' }), { clean: true, conflicts: [] });
|
||||
writeFileSync(join(dir, 'a.txt'), 'dev\n');
|
||||
git('commit', '-q', '-am', 'dev conflicts');
|
||||
const conflict = readMergeState({ cwd: dir, onto: 'dev', ref: 'task' });
|
||||
assert.deepEqual(conflict, { clean: false, conflicts: ['a.txt'] });
|
||||
assert.match(renderPacket(packetOf({ labels: ['S6-in-progress', 'track:show'], branch: branchWith({ behind: 2, mergeClean: conflict.clean, conflicts: conflict.conflicts }) })),
|
||||
/слияние с dev конфликтует: a\.txt — ребейз до S7/);
|
||||
// git < 2.38: `--write-tree` неизвестен — usage и код 129; это не «конфликта нет».
|
||||
const old = () => ({ status: 129, stdout: '', stderr: 'usage: git merge-tree <base-tree> <branch1> <branch2>' });
|
||||
assert.deepEqual(readMergeState({ cwd: dir, onto: 'dev', ref: 'task', run: old }), { clean: null, conflicts: [] });
|
||||
assert.deepEqual(readMergeState({ cwd: dir, run: () => ({ error: new Error('ENOENT') }) }), { clean: null, conflicts: [] });
|
||||
});
|
||||
|
||||
const TOUCH = DIFF('src/pointer-modality.ts', " if (e.pointerType === 'touch') return;", 7);
|
||||
const RENDER = DIFF('src/render/paper-scene.ts', ' const scale = 2;');
|
||||
const GEOMETRY = DIFF('src/wall-merge.ts', ' const merged = a + b;');
|
||||
const STYLE = DIFF('src/styles/plan.styles.ts', ' gap: 4px;');
|
||||
|
||||
test('#707 AC9: риск по участкам и следствие по треку', () => {
|
||||
const risk = (labels, comments = []) => packetOf({ labels: ['S6-in-progress', ...labels], comments, branch: branchWith({ diff: TOUCH }) });
|
||||
const confirmed = [{ author: 'Matysh', body: 'Трек: ship — решение владельца', createdAt: '2026-09-30T08:00:00Z' }];
|
||||
assert.equal(risk(['track:ship']).risk.consequence, 'конвейер повысит до show при S7');
|
||||
assert.equal(risk(['track:ship'], confirmed).risk.consequence, 'не повысит; риск прочтёт пакетное ревью');
|
||||
assert.equal(risk(['track:show']).risk.consequence, 'ревьюер спросит, где поведение зафиксировано; нет ссылки — повысить до ask до S7 (§5)');
|
||||
assert.equal(risk(['track:ask']).risk.consequence, 'справочно');
|
||||
const md = renderPacket(risk(['track:ship']));
|
||||
assert.match(md, /## Риск по участкам\n- touch: src\/pointer-modality\.ts:7 · участок pointer-modality, токен pointerType\n- следствие: конвейер повысит до show при S7\n/);
|
||||
assert.match(renderPacket(packetOf({ branch: null })), /## Риск по участкам\n- не посчитан: ветки нет\n/);
|
||||
assert.doesNotMatch(renderPacket(packetOf({ branch: branchWith({ diff: DIFF('scripts/x.mjs', 'pointerdown') }) })), /## Риск по участкам/,
|
||||
'пустой раздел не печатается');
|
||||
assert.equal(JSON.parse(JSON.stringify(risk(['track:ship']))).risk.classes[0], 'touch', '--json несёт риск');
|
||||
});
|
||||
|
||||
test('#707 AC10: обязательные проверки с основаниями', () => {
|
||||
const checksOf = (over, labels = []) => packetOf({ labels: ['S6-in-progress', ...labels], branch: branchWith(over) }).checks;
|
||||
const commands = (list) => list.map((c) => c.command);
|
||||
const base = checksOf({});
|
||||
assert.deepEqual(base, [{ command: '`npm run gate:small`', reason: 'всегда (§8)' }], 'gate:small — всегда');
|
||||
assert.deepEqual(packetOf({ branch: null }).checks.map((c) => c.command), ['`npm run gate:small`']);
|
||||
const smokes = checksOf({ smokes: { direct: [{ smoke: 'smoke_a.mjs', symbols: ['_wallA'] }], registered: [{ smoke: 'smoke_b.mjs', symbols: ['_b'] }], visualMinimum: ['smoke_modes.mjs'] } });
|
||||
assert.deepEqual(smokes.slice(1).map((c) => `${c.command} · ${c.reason}`), [
|
||||
'`node demo/smoke_a.mjs` · smoke-select: прямое совпадение (_wallA)',
|
||||
'`node demo/smoke_b.mjs` · smoke-select: зарегистрированная связь (_b)',
|
||||
'`npm run gate:small -- --smokes` · smoke-select: визуальный минимум — связь диффа со смоками не доказана (#690): smoke_modes',
|
||||
]);
|
||||
assert.ok(commands(checksOf({ diff: GEOMETRY })).includes('`npm run invariants -- --config <экспорт>`'), 'invariants при geometry');
|
||||
assert.ok(!commands(checksOf({ diff: TOUCH })).some((c) => c.includes('invariants')), 'без geometry — нет');
|
||||
assert.ok(commands(checksOf({ changedFiles: ['custom_components/houseplan/store.py'] })).includes('`python -m pytest tests_backend -q`'));
|
||||
assert.ok(!commands(checksOf({ changedFiles: ['src/a.ts', 'tests_backend/test_x.py'] })).some((c) => c.includes('pytest')), 'pytest — только при custom_components/**/*.py');
|
||||
for (const mirror of ['src/junction-limits.ts', 'custom_components/houseplan/junction_limits.py', 'test/fixtures/junction-limits-parity.json']) {
|
||||
assert.ok(commands(checksOf({ changedFiles: [mirror] })).some((c) => c.includes('junction_parity.py')), mirror);
|
||||
}
|
||||
assert.ok(!commands(checksOf({ changedFiles: ['src/wall-merge.ts'] })).some((c) => c.includes('junction_parity')));
|
||||
const golden = (over, labels) => checksOf(over, labels).find((c) => c.command.includes('ci:golden'));
|
||||
assert.match(golden({ diff: RENDER }).reason, /^рекомендовано, если сдвиг кадров намерен/, 'ci:golden — при visual/render');
|
||||
assert.match(golden({ diff: RENDER }, ['ci:golden']).reason, /^стоит/);
|
||||
assert.equal(golden({ diff: STYLE }), undefined, 'visual/ui — без ci:golden');
|
||||
assert.equal(golden({ diff: DIFF('src/render/paper-scene.ts', ' // stroke-width: 2') }), undefined, 'только комментарии — без ci:golden');
|
||||
assert.equal(golden({ diff: DIFF('test/render.test.mjs', " stroke-width='2'"), changedFiles: ['test/render.test.mjs'] }), undefined, 'только тесты — без ci:golden');
|
||||
assert.match(renderPacket(packetOf({ branch: branchWith({ diff: GEOMETRY }) })), /## Обязательные проверки\n- `npm run gate:small` · всегда \(§8\)\n- `npm run invariants -- --config <экспорт>` · риск geometry/);
|
||||
});
|
||||
|
||||
test('#707 AC11: changelog и визуальное свидетельство', () => {
|
||||
const commits = [
|
||||
{ message: 'fix: a (#707)\n\nIssue: #707\nUser-Visible: yes\n' },
|
||||
{ message: 'test: b (#707)\n\nIssue: #707\nUser-Visible: no\n' },
|
||||
{ message: 'docs: c' },
|
||||
];
|
||||
const p = packetOf({ branch: branchWith({ commits, changedFiles: ['src/a.ts', 'docs/CHANGELOG.md'] }) });
|
||||
assert.deepEqual(p.changelog, { yes: 1, no: 1, missing: ['docs/CHANGELOG.ru.md'], visualEvidence: false });
|
||||
const md = renderPacket(p);
|
||||
assert.match(md, /## Changelog и визуальное свидетельство\n- коммитов ветки: `User-Visible: yes` — 1, `User-Visible: no` — 1\n- не хватает docs\/CHANGELOG\.ru\.md/);
|
||||
assert.deepEqual(packetOf({ branch: branchWith({ commits, changedFiles: ['docs/CHANGELOG.md', 'docs/CHANGELOG.ru.md'] }) }).changelog.missing, []);
|
||||
assert.deepEqual(packetOf({ branch: branchWith({ commits: [commits[1]] }) }).changelog.missing, [], 'User-Visible: no changelog не требует');
|
||||
const visual = renderPacket(packetOf({ branch: branchWith({ diff: RENDER }) }));
|
||||
assert.match(visual, /дефект растра или резкости требует свидетеля, красного на старом коде, и подтверждения владельца в GPU-браузере \(§7\.1\)/);
|
||||
assert.doesNotMatch(renderPacket(packetOf({ branch: branchWith({ diff: STYLE }) })), /## Changelog/, 'нет коммитов и нет визуала — раздел не печатается');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user