mirror of
https://github.com/Matysh/houseplan-card
synced 2026-09-30 11:49:16 +00:00
show/ship stop paying for diff mutants and for every move of dev: - scripts/process-track.mjs resolves the track from the current labels and the diff (show for unlabelled infra, ask for unlabelled product work) and checks the mechanical ship limits; outside them the pipeline comments and relabels track:ship -> track:show in the same round. - Validate on the review material is light on show/ship: a completed push run on the exact SHA is proof, a dispatch asks mutants=false. ask and the ci:mutants label keep the mutant dispatch. - show/ship skip the pre-review rebase when git merge-tree with dev is clean; the candidate is rebased once at merge and still passes Validate before the push to dev. The light merge waits for the push run of the candidate and dispatches only when none appears. - ship inside the limits merges after the light Validate without a model review; the issue gets a machine marker hp:ship-merge. - ship-review.yml + scripts/ship-review.mjs read the code of all ship tasks of a beta range in one model session and publish docs/reviews/SHIP-REVIEW-<tag>.md; both beta publication paths refuse a range with ship tasks the document does not cover or that carries a High. - show reviews judge correctness and AC; the spec review installs neither npm ci nor Chromium, the show review installs Chromium only when the issue names a smoke. Canon: PROCESS.md §5, §5.1, §10.4, new §11.7; REVIEWER.md, AUTHOR.md and AGENTS.md digests. Issue: #696 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
171 lines
12 KiB
JavaScript
171 lines
12 KiB
JavaScript
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 { 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,
|
|
} from '../scripts/process-track.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: true, infrastructure: true });
|
|
assert.deepEqual(resolveTrack({ labels: ['track:ship'], files: ['src/a.ts'] }), { track: 'ship', mutants: 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('мутанты по диффу — только ask и метка ci:mutants (#696)', () => {
|
|
assert.equal(resolveTrack({ labels: ['track:show'], files: ['src/a.ts'] }).mutants, false);
|
|
assert.equal(resolveTrack({ labels: ['track:show', 'ci:mutants'], files: ['src/a.ts'] }).mutants, true);
|
|
assert.equal(resolveTrack({ labels: ['track:ship', 'ci:mutants'], files: ['src/a.ts'] }).mutants, true);
|
|
assert.equal(resolveTrack({ labels: [], files: ['src/a.ts'] }).mutants, true);
|
|
});
|
|
|
|
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 не ребейзится');
|
|
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 — только в рамках');
|
|
assert.match(trackStep, /--add-label track:show --remove-label track:ship/, 'выход за рамки повышает трек');
|
|
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:-true\}"/, 'по умолчанию — с мутантами');
|
|
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:-true\}"/);
|
|
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'/);
|
|
});
|