mirror of
https://github.com/Matysh/houseplan-card
synced 2026-10-02 04:38:55 +00:00
The ship limits count lines and files but not what was touched: a 12-line pointerdown handler passed them like a typo and merged unread. The track rule also lived twice - the guard computed the cycle limit in bash while process-track.mjs computed the track, and the two disagreed on multiple track labels. The packet still told authors to rebase show/ship branches that merge cleanly. - scripts/change-risk.mjs: one pure classifier over `git diff -U0` from the merge base. Class A lines only; comments, blank lines and pure renames give no risk; deletions do. Area and token rules per class (geometry, touch, migration, devices, perf, ux, visual render/ui), evidence as path:line, five per class. - process-track.mjs: owner confirmation is a comment line "Трек: <x> — решение владельца" by the repo owner (latest wins, only for the current track); several track labels read as the strictest with a warning; cycleLimit, guardLimit and rebaseBeforeReview are the single source. `stage` makes the whole S7 track decision in one call: ship with risk and no confirmation is raised to show with evidence, a confirmed ship keeps merging without the model and records the risk for the batch review; show/ask get a risk note for the reviewer. - _process.yml: the guard asks process-track.mjs for the limit and keeps no track logic; the track step calls the script once and only executes its raise flag and comment file; risk_note reaches the Review prompt, ship_risk reaches the hp:ship-merge comment (marker line unchanged). - task-packet.mjs: track basis, limit and rebase policy; next step without the stale rebase line; risk with its consequence per track; required checks with reasons (ci:golden only on render risk); changelog and visual evidence - from the same exports. - ship-review.mjs: the batch brief prints the risk line of a ship merge. - Canon: PROCESS.md §5, §5.1, §10.4, §11.7, both digests, AGENTS.md. - Registry anchors that watched the moved code are moved, not dropped. Issue: #707 User-Visible: no Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
141 lines
11 KiB
JavaScript
141 lines
11 KiB
JavaScript
// #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);
|
||
});
|