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

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

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

141 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// #696, PROCESS.md §11.7: задачи track:ship сливаются без ревью модели; их код
// читает пакетное ревью диапазона перед бетой, и гейт беты требует документ.
import assert from 'node:assert/strict';
import test from 'node:test';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
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';
const sha = (c) => c.repeat(40);
const MARKER = `<!-- hp:ship-merge material=${sha('a')} -->`;
test('#696 документ — по тегу беты или стабильному, имя фиксировано', () => {
assert.equal(shipReviewDocPath('v1.79.0-beta.1'), 'docs/reviews/SHIP-REVIEW-v1.79.0-beta.1.md');
assert.equal(shipReviewDocPath('v1.79.0'), 'docs/reviews/SHIP-REVIEW-v1.79.0.md');
for (const bad of ['1.79.0', 'v1.79', 'v1.79.0-rc.1', '', 'v1.79.0/../x']) {
assert.throws(() => shipReviewDocPath(bad), /not a release tag/, bad);
}
});
test('#696 ship-задача — по маркеру конвейера или метке track:ship', () => {
assert.equal(isShipIssue({ labels: [], comments: [{ body: `Слияние без ревью модели\n\n${MARKER}` }] }), true);
assert.equal(isShipIssue({ labels: [{ name: 'track:ship' }], comments: [] }), true);
assert.equal(isShipIssue({ labels: ['track:show'], comments: [{ body: 'hp:ship-merge без маркера' }] }), false);
assert.equal(isShipIssue({}), false);
});
test('#696 состав — трейлеры диапазона; show и ask в пакет не входят', () => {
const commits = [
{ sha: sha('c'), message: 'fix: text\n\nIssue: #701\nUser-Visible: yes' },
{ sha: sha('b'), message: 'feat: y\n\nIssue: #702\nUser-Visible: yes' },
{ sha: sha('a'), message: 'fix: css\n\nIssue: #701\nUser-Visible: yes' },
{ sha: sha('d'), message: 'chore: упоминание #703 — не трейлер' },
];
const data = {
701: { title: 'Опечатка', body: 'Отчёт\n\n## ТЗ\n\nЗаменить «Сохранть» на «Сохранить»; видно в диалоге.\n\n## Прочее\nх', labels: [], comments: [{ body: MARKER }] },
702: { title: 'Фича', body: '', labels: [{ name: 'track:show' }], comments: [] },
};
const ship = shipIssuesInRange({ commits, issueData: (n) => data[n] ?? null });
assert.deepEqual(ship.map((i) => i.number), [701]);
assert.deepEqual(ship[0].commits.map((c) => c.sha), [sha('a'), sha('c')], 'коммиты по порядку истории');
assert.equal(ship[0].spec, '## ТЗ\n\nЗаменить «Сохранть» на «Сохранить»; видно в диалоге.');
const brief = renderShipBrief({ tag: 'v1.79.0-beta.1', candidate: sha('e'), base: { tag: 'v1.78.0', sha: sha('f') }, ship });
assert.match(brief, /### #701 · Опечатка/);
assert.match(brief, /SHIP-REVIEW-v1\.79\.0-beta\.1\.md/);
assert.match(brief, new RegExp(sha('a')));
assert.equal(specSection('без раздела'), '');
});
test('#696 гейт: без ship-задач документ не нужен, без документа — отказ с командой', () => {
assert.deepEqual(shipReviewProblems({ tag: 'v1.79.0-beta.1', ship: [], docText: null }), []);
const [problem] = shipReviewProblems({ tag: 'v1.79.0-beta.1', ship: [{ number: 701 }], docText: null });
assert.match(problem, /#701/);
assert.match(problem, /gh workflow run ship-review\.yml --ref dev -f tag=v1\.79\.0-beta\.1/);
});
test('#696 гейт: машинный блок покрывает все задачи и не несёт High', () => {
const tag = 'v1.79.0-beta.1';
const doc = (fields) => `# Ревью\nИтог: …\n\n${anchorBlock({ tag, candidate: sha('e'), issues: [701, 704], ...fields })}`;
assert.deepEqual(parseAnchorBlock(doc({ high: 0, medium: 1, low: 2 })), {
tag, candidate: sha('e'), issues: [701, 704], high: 0, medium: 1, low: 2,
});
assert.deepEqual(shipReviewProblems({ tag, ship: [{ number: 701 }, { number: 704 }], docText: doc({ high: 0 }) }), []);
const partial = shipReviewProblems({ tag, ship: [{ number: 701 }, { number: 709 }], docText: doc({ high: 0 }) });
assert.equal(partial.length, 1);
assert.match(partial[0], /не покрывает ship-задачи #709/);
const high = shipReviewProblems({ tag, ship: [{ number: 701 }], docText: doc({ high: 2 }) });
assert.equal(high.length, 1);
assert.match(high[0], /High 2/);
assert.match(shipReviewProblems({ tag, ship: [{ number: 701 }], docText: '# без блока' })[0], /без машинного блока/);
assert.match(shipReviewProblems({ tag: 'v1.79.0-beta.2', ship: [{ number: 701 }], docText: doc({ high: 0 }) })[0], /для тега v1\.79\.0-beta\.1/);
});
test('#696 индекс и архив знают SHIP-REVIEW: бета в индексе, архив — каталог стабильной линии', () => {
assert.deepEqual(parseDocName('SHIP-REVIEW-v1.79.0-beta.1.md'), { stage: 'ship', issue: null, round: null, suffix: null, tag: 'v1.79.0-beta.1' });
const md = renderIndex({ entries: [
{ name: 'SHIP-REVIEW-v1.79.0-beta.1.md', stage: 'ship', issue: null, tag: 'v1.79.0-beta.1', verdict: '—', high: 0, medium: 1, findings: [], files: [] },
{ name: 'SHIP-REVIEW-v1.79.0-beta.2.md', stage: 'ship', issue: null, tag: 'v1.79.0-beta.2', verdict: '—', high: 0, medium: 0, findings: [], files: [] },
{ name: 'RELEASE-REVIEW-v1.79.0.md', stage: 'release', issue: null, tag: 'v1.79.0', verdict: '—', high: 1, medium: 0, findings: [], files: [] },
] });
const rows = md.split('\n').filter((line) => line.startsWith('| бета') || line.startsWith('| линия'));
assert.deepEqual(rows.map((r) => r.split('|')[1].trim()), ['линия v1.79.0', 'бета v1.79.0-beta.2', 'бета v1.79.0-beta.1']);
assert.match(rows[1], /пакетное ревью ship · —/);
const plan = archivePlan({
names: ['SHIP-REVIEW-v1.79.0-beta.1.md', 'SHIP-REVIEW-v1.80.0-beta.1.md'],
lines: [{ tag: 'v1.79.0', issues: [] }], open: [], through: 'v1.79.0',
});
assert.deepEqual(plan.moves.map((m) => m.to), ['legacy/reviews/v1.79.0/SHIP-REVIEW-v1.79.0-beta.1.md']);
assert.ok(plan.kept.some((k) => k.name === 'SHIP-REVIEW-v1.80.0-beta.1.md'));
});
test('#696 _ship-review.yml: модель без права записи, документ с машинным блоком в dev', () => {
// #716: тело из dev; тонкий `ship-review.yml` в main — кнопка и потолок прав.
const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/_ship-review.yml', import.meta.url)), 'utf8');
const model = workflow.slice(workflow.indexOf('\n model_review:'), workflow.indexOf('\n publish:'));
assert.match(model, /permissions:\n\s+contents: read\n\s+steps:/, 'модель только читает');
assert.match(model, /github_token: \$\{\{ secrets\.GITHUB_TOKEN \}\}/, 'без обмена OIDC на App-токен (#556)');
const publish = workflow.slice(workflow.indexOf('\n publish:'));
assert.match(publish, /m\.anchorBlock\(/, 'машинный блок пишет публикация, не модель');
assert.match(publish, /reviews-index\.mjs --dir=docs\/reviews --strict/);
assert.match(workflow, /if: needs\.prepare\.outputs\.proceed == 'true'/);
assert.match(workflow, /ship-задач в диапазоне нет — ревью не нужно/);
});
test('#696 оба пути публикации беты проверяют пакетное ревью ship до выпуска', () => {
const workflow = readFileSync(fileURLToPath(new URL('../.github/workflows/publish-prerelease.yml', import.meta.url)), 'utf8');
const gate = workflow.slice(workflow.indexOf('\n gate:'), workflow.indexOf('\n publish:'));
assert.match(gate, /node scripts\/ship-review\.mjs check --tag="\$TAG" --candidate="\$SHA"/, 'гейт CI-публикации');
const local = readFileSync(fileURLToPath(new URL('../scripts/release-prerelease.mjs', import.meta.url)), 'utf8');
const main = local.slice(local.indexOf('const main = async'));
const check = main.indexOf("'scripts/ship-review.mjs', 'check'");
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);
});