fix(process): prepare and the usage step run pipeline scripts from one pinned dev snapshot (#765)

The body of _process.yml is read from dev (@dev, #623). After "Перейти на
ветку задачи" the working copy of job prepare is the task branch, and a
show/ship branch with a clean merge is not rebased before review: its
scripts/ may lag dev or be replaced. #749 fixed job integrate; prepare
still ran four control scripts from the material — the issue-body digest
for the anchor, --reuse of a green verdict (#499), validate-gate (#510)
and the spec-change check (#517). The material decided its own admission:
a branch whose review-doc-guard.mjs prints reuse=true merges without the
model. model_review took model-usage.mjs from the material too: a lagging
branch has none, and the publication silently wrote reason=missing.

Now prepare extracts one snapshot right after setup-node, before the
branch switch: `git rev-parse origin/dev` once and `git archive <sha>
scripts .github/workflows/validate.yml`, so the git fetch of the track and
rebase steps cannot mix versions. Every repo script of the job runs from
it via TOOLS — the track step and the rebase guard lose their own
extractions. The SHA goes out as job output tools_sha; the usage step of
model_review archives the same commit inside itself, so a snapshot failure
is a failure of the reporting step (continue-on-error), not of the stage.
The model session runs on that runner, so the usage line stays untrusted
input parsed strictly (#556, #737).

withMaterialAnchors is idempotent: a repeated call drops the separator the
previous call wrote instead of piling up `---` lines.

Tests: test/process-prepare-tools.test.mjs — the job contract (no step
calls scripts/ from the working copy, one pinned archive before the branch
switch, tools_sha reaches model_review) and the steps as they are, on real
bash and git: a branch behind dev without model-usage.mjs and with a
substituted review-doc-guard.mjs; the anchor digest, reuse and the spec
check come from dev, the usage line is data even after dev moved. Red on
the old workflow: all four. Harnesses of process-track, rebase-generated,
review-doc-guard and model-usage take the prepare snapshot. Three registry
mutants (reuse from the material, usage from moving dev, separators).

Canon: PROCESS.md §10.4 «Скрипты конвейера — из dev» covers prepare and
the usage step; «Расход модели» names it a pipeline step, not the reviewer's.

Issue: #765
User-Visible: no
This commit is contained in:
claude[bot]
2026-10-02 10:18:29 +03:00
parent ceee249210
commit 23f822475b
10 changed files with 462 additions and 49 deletions
+19 -6
View File
@@ -199,9 +199,19 @@ test('#737 lastUsageIn: последняя строка формата в тек
// ---------- К3: проводка в обоих workflow ----------
const WORKFLOWS = fileURLToPath(new URL('../.github/workflows/', import.meta.url));
// #765: в _process.yml рабочая копия model_review — материал ревью (ветка задачи
// может отставать от dev или подменять скрипт), поэтому скрипт берётся снимком
// того коммита dev, что закрепила подготовка (`tools_sha`). В _ship-review.yml
// рабочая копия — кандидат линии dev, и скрипт зовётся из неё.
const PIPELINES = [
{ file: '_process.yml', publishJob: 'integrate', publishStep: 'Опубликовать документ ревью', consumes: /^ +--usage="\$USAGE" \\$/m },
{ file: '_ship-review.yml', publishJob: 'publish', publishStep: 'Опубликовать документ', consumes: /^ +usage: process\.env\.USAGE \?\? "",$/m },
{
file: '_process.yml', publishJob: 'integrate', publishStep: 'Опубликовать документ ревью', consumes: /^ +--usage="\$USAGE" \\$/m,
call: /^line=\$\(node "\$tools\/scripts\/model-usage\.mjs" --execution-file="\$EXEC"\)$/m, snapshot: true,
},
{
file: '_ship-review.yml', publishJob: 'publish', publishStep: 'Опубликовать документ', consumes: /^ +usage: process\.env\.USAGE \?\? "",$/m,
call: /^line=\$\(node scripts\/model-usage\.mjs --execution-file="\$EXEC"\)$/m, snapshot: false,
},
];
/** Блок job верхнего уровня `jobs:` — до следующего id на двух пробелах. */
@@ -234,7 +244,7 @@ const named = (steps, name) => {
const hasBash = () => process.platform !== 'win32' && spawnSync('bash', ['--version']).status === 0;
test('#737 AC5: шаг снятия расхода сразу после Review — always, continue-on-error, выход job usage; публикация его берёт', () => {
for (const { file, publishJob, publishStep, consumes } of PIPELINES) {
for (const { file, publishJob, publishStep, consumes, call } of PIPELINES) {
const text = readFileSync(join(WORKFLOWS, file), 'utf8');
const model = jobBlock(text, 'model_review');
const steps = stepsOf(model);
@@ -247,7 +257,7 @@ test('#737 AC5: шаг снятия расхода сразу после Review
assert.match(usage, /^ {8}continue-on-error: true$/m, `${file}: сбой снятия не роняет стадию`);
assert.match(usage, /^ {10}EXEC: \$\{\{ steps\.review\.outputs\.execution_file \}\}$/m, file);
const run = runOf(usage);
assert.match(run, /^line=\$\(node scripts\/model-usage\.mjs --execution-file="\$EXEC"\)$/m, file);
assert.match(run, call, file);
assert.match(run, /^echo "line=\$line" >> "\$GITHUB_OUTPUT"$/m, file);
assert.match(run, /"\$GITHUB_STEP_SUMMARY"$/m, `${file}: строка — в сводку шага`);
const head = model.slice(0, model.indexOf('\n steps:\n'));
@@ -281,7 +291,9 @@ test('#737 AC5: execution_file никуда не выгружается, гра
test('#737 AC5: шаг снятия расхода на настоящем bash — выход line и строка в сводке, секрета нет', (t) => {
if (!hasBash()) { t.skip('bash недоступен'); return; }
for (const { file } of PIPELINES) {
// #765: снимок — коммит HEAD этого дерева (скрипт расхода в нём тот же).
const head = spawnSync('git', ['rev-parse', 'HEAD'], { cwd: ROOT, encoding: 'utf8' }).stdout.trim();
for (const { file, snapshot } of PIPELINES) {
const text = readFileSync(join(WORKFLOWS, file), 'utf8');
const body = runOf(named(stepsOf(jobBlock(text, 'model_review')), 'Снять расход модели'));
const dir = tempDir(t);
@@ -293,7 +305,8 @@ test('#737 AC5: шаг снятия расхода на настоящем bash
rmSync(output, { force: true });
rmSync(summary, { force: true });
const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', body], {
cwd: ROOT, encoding: 'utf8', env: { ...process.env, EXEC, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary },
cwd: ROOT, encoding: 'utf8',
env: { ...process.env, EXEC, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, ...(snapshot ? { TOOLS_SHA: head, RUNNER_TEMP: dir } : {}) },
});
assert.equal(r.status, 0, `${file}: ${r.stderr}`);
assert.equal(readFileSync(output, 'utf8'), `line=${line}\n`, file);
+276
View File
@@ -0,0 +1,276 @@
// #765: подготовка и снятие расхода исполняют скрипты конвейера из снимка `dev`.
//
// Продолжение #749 (там — job `integrate`). Тело `_process.yml` читается из
// `dev` (`@dev`, #623), а рабочая копия `prepare` после «Перейти на ветку
// задачи» — материал: ветка show/ship с чистым слиянием до ревью не ребейзится
// (§10.4) и может нести `scripts/`, отставшие на дни или подменённые. Раньше
// якорь тела issue, reuse (#499), Validate-gate (#510) и проверка ТЗ (#517)
// исполняли скрипты ветки, а `model_review` снимал расход (#737) скриптом из
// материала: на отставшей ветке его нет, и публикация молча писала
// `reason=missing`.
//
// Здесь — контракт двух job (ни одного вызова `scripts/` из рабочей копии, один
// снимок на заход, SHA закреплён и передан в `model_review`) и исполнение
// шагов как есть, настоящим bash и git: ветка задачи отстала от dev (скрипта
// расхода нет) и подменяет review-doc-guard.mjs и model-usage.mjs; подмена не
// исполняется, а вывод привязан к материалу.
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { chmodSync, cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { basename, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { issueBodyDigest } from '../scripts/review-doc-guard.mjs';
import { formatUsage } from '../scripts/model-usage.mjs';
const ROOT = fileURLToPath(new URL('..', import.meta.url));
const WORKFLOW = join(ROOT, '.github', 'workflows', '_process.yml');
const TOOLS_STEP = 'Скрипты конвейера — из dev (#765)';
const TOOLS_ENV = 'TOOLS: ${{ steps.tools.outputs.dir }}';
const USAGE_STEP = 'Снять расход модели';
/** Блок job: от ` <id>:` до следующей job на том же отступе. */
function jobBlock(text, id) {
const start = text.indexOf(`\n ${id}:\n`);
assert.ok(start >= 0, `job ${id}`);
const rest = text.slice(start + 1);
const next = rest.slice(1).search(/\n {2}[\w-]+:\n/);
return next < 0 ? rest : rest.slice(0, next + 2);
}
/** Шаги job: имя, id, env и тело `run` так, как его прочтёт YAML; `code` — без комментариев. */
function stepsOf(job) {
const lines = job.slice(job.indexOf('\n steps:\n') + 1).split('\n').slice(1);
const steps = [];
for (const line of lines) {
if (/^ {6}- /.test(line)) steps.push([line]);
else if (steps.length) steps.at(-1).push(line);
}
return steps.map((stepLines) => {
const text = stepLines.join('\n');
const field = (key) => text.match(new RegExp(`^ {6}(?:- | {2})${key}: (.+)$`, 'm'))?.[1];
const env = [];
const envAt = stepLines.indexOf(' env:');
if (envAt >= 0) {
for (const line of stepLines.slice(envAt + 1)) {
if (/^ {10}#/.test(line)) continue;
if (!/^ {10}\S/.test(line)) break;
env.push(line.trim());
}
}
let run = '';
const runAt = stepLines.indexOf(' run: |');
if (runAt >= 0) {
const body = [];
for (const line of stepLines.slice(runAt + 1)) {
if (line.trim() && !/^ {10}/.test(line)) break;
body.push(line.slice(10));
}
run = body.join('\n');
}
const code = run.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n');
return { name: field('name') ?? field('uses'), id: field('id'), env, run, code };
});
}
const workflow = () => readFileSync(WORKFLOW, 'utf8');
const prepareSteps = () => stepsOf(jobBlock(workflow(), 'prepare'));
const modelSteps = () => stepsOf(jobBlock(workflow(), 'model_review'));
const named = (steps, name) => {
const step = steps.find((item) => item.name === name || item.name === `"${name}"`);
assert.ok(step, `шаг «${name}»`);
return step;
};
// Вызов repo-скрипта не через снимок: `scripts/` без `$TOOLS/`, `${…TOOLS}/`
// или `$tools/` шага расхода перед ним; import() относительного пути.
const LOCAL_CALL = /(?<!\$TOOLS\/|TOOLS\}\/|\$tools\/)\bscripts\/[\w.-]+\.mjs/;
const LOCAL_IMPORT = /import\(\s*['"`]\.{0,2}\/?scripts\//;
test('#765 AC1: в job prepare ни один шаг не зовёт скрипт из рабочей копии — только из снимка dev', () => {
const calls = [];
for (const step of prepareSteps()) {
assert.doesNotMatch(step.code, LOCAL_CALL, `«${step.name}»: repo-скрипт мимо снимка — рабочая копия здесь ветка задачи`);
assert.doesNotMatch(step.code, LOCAL_IMPORT, `«${step.name}»: import() скрипта рабочей копии`);
if (step.name !== TOOLS_STEP) {
assert.doesNotMatch(step.code, /\$tools\b|git archive/, `«${step.name}»: своё извлечение скриптов вместо снимка захода`);
}
const used = [
...[...step.code.matchAll(/node "\$TOOLS\/scripts\/([\w.-]+\.mjs)"/g)].map((m) => m[1]),
...[...step.code.matchAll(/process\.env\.TOOLS\}\/scripts\/([\w.-]+\.mjs)/g)].map((m) => m[1]),
];
if (used.length) assert.ok(step.env.includes(TOOLS_ENV), `«${step.name}»: каталог снимка — из выхода шага tools`);
for (const script of used) calls.push(`${step.name}: ${script}`);
}
const count = (name) => calls.filter((call) => call.endsWith(`: ${name}`)).length;
assert.deepEqual(
Object.fromEntries(['process-track.mjs', 'rebase-generated.mjs', 'merge-candidate.mjs', 'review-doc-guard.mjs', 'validate-gate.mjs']
.map((name) => [name, count(name)])),
// review-doc-guard: хеш тела для якоря, --reuse (#499), изменившееся ТЗ (#517).
{ 'process-track.mjs': 1, 'rebase-generated.mjs': 1, 'merge-candidate.mjs': 1, 'review-doc-guard.mjs': 3, 'validate-gate.mjs': 1 },
calls.join('\n'),
);
});
test('#765 AC1: снимок — один на заход, SHA dev закреплён до перехода на ветку и передан в model_review', () => {
const steps = prepareSteps();
const at = steps.findIndex((step) => step.name === TOOLS_STEP);
assert.ok(at >= 0, `шаг «${TOOLS_STEP}»`);
const snapshot = steps[at];
assert.equal(snapshot.id, 'tools');
assert.ok(steps.findIndex((step) => /^actions\/setup-node@/.test(step.name)) < at, 'после setup-node');
assert.ok(at < steps.findIndex((step) => step.name === 'Перейти на ветку задачи'), 'до перехода на материал');
assert.match(snapshot.code, /^set -euo pipefail$/m, 'сбой git archive не проходит молча через | tar');
assert.match(snapshot.code, /^git fetch -q origin dev$/m);
assert.match(snapshot.code, /^sha=\$\(git rev-parse origin\/dev\)$/m, 'SHA закрепляется один раз');
assert.match(snapshot.code, /^git archive "\$sha" scripts \.github\/workflows\/validate\.yml \| tar -x -C "\$tools"$/m,
'архив закреплённого SHA, а не движущейся origin/dev; validate.yml читает ci-proof.mjs по пути от себя');
assert.match(snapshot.code, /^\{ echo "dir=\$tools"; echo "sha=\$sha"; \} >> "\$GITHUB_OUTPUT"$/m);
const head = jobBlock(workflow(), 'prepare');
assert.match(head.slice(0, head.indexOf('\n steps:\n')), /^ {6}tools_sha: \$\{\{ steps\.tools\.outputs\.sha \}\}$/m, 'выход job tools_sha');
const usage = named(modelSteps(), USAGE_STEP);
assert.ok(usage.env.includes('TOOLS_SHA: ${{ needs.prepare.outputs.tools_sha }}'), 'расход — из того же коммита dev');
assert.doesNotMatch(usage.code, LOCAL_CALL, 'model-usage.mjs — не из материала');
assert.match(usage.code, /^git archive "\$TOOLS_SHA" scripts \| tar -x -C "\$tools"$/m);
assert.match(usage.code, /^line=\$\(node "\$tools\/scripts\/model-usage\.mjs" --execution-file="\$EXEC"\)$/m);
for (const step of modelSteps()) {
assert.doesNotMatch(step.code, LOCAL_CALL, `model_review «${step.name}»: repo-скрипт из материала`);
}
});
const hasTools = () => process.platform !== 'win32'
&& ['bash', 'tar', 'git'].every((tool) => spawnSync(tool, ['--version']).status === 0);
// Окружение git без GIT_* родителя и без глобального конфига (урок #633, #496).
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',
GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'init.defaultBranch', GIT_CONFIG_VALUE_0: 'dev',
};
const BODY = '## ТЗ\n\nтело задачи\n';
const USAGE = { input_tokens: 11, output_tokens: 22, cache_creation_input_tokens: 33, cache_read_input_tokens: 44, num_turns: 5 };
const EVIL = "#!/usr/bin/env node\nprocess.stdout.write('reuse=true\\ndoc=CODE-REVIEW-7-r1.md\\nround=1\\ntree=x\\n');\n"
+ 'export const issueBodyDigest = () => "подменено"; export const issueBodyChanged = () => ({ doc: "x", recorded: "y" });\n';
/**
* Временный origin: dev несёт нынешние scripts/ и validate.yml; ветка задачи
* ответвлена раньше — model-usage.mjs в ней нет — и подменяет
* review-doc-guard.mjs скриптом, который выдаёт себе reuse зелёного вердикта.
*/
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'hp-765-'));
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 temp = join(root, 'runner');
const bin = join(root, 'bin');
mkdirSync(temp);
mkdirSync(bin);
git(root, 'init', '--bare', '-q', origin);
git(root, 'clone', '-q', origin, work);
git(work, 'checkout', '-q', '-b', 'dev');
writeFileSync(join(work, 'README.md'), 'старый dev\n');
git(work, 'add', '-A');
git(work, 'commit', '-q', '-m', 'старый dev');
const old = git(work, 'rev-parse', 'HEAD');
cpSync(join(ROOT, 'scripts'), join(work, 'scripts'), {
recursive: true, filter: (src) => !['node_modules', '__pycache__'].includes(basename(src)),
});
mkdirSync(join(work, '.github', 'workflows'), { recursive: true });
cpSync(join(ROOT, '.github', 'workflows', 'validate.yml'), join(work, '.github', 'workflows', 'validate.yml'));
git(work, 'add', '-A');
git(work, 'commit', '-q', '-m', 'dev');
git(work, 'push', '-q', 'origin', 'dev');
git(work, 'checkout', '-q', '-b', 'issue/7-x', old);
mkdirSync(join(work, 'scripts'), { recursive: true });
writeFileSync(join(work, 'scripts', 'review-doc-guard.mjs'), EVIL);
writeFileSync(join(work, 'src.txt'), 'работа задачи\n');
git(work, 'add', '-A');
git(work, 'commit', '-q', '-m', 'ветка задачи');
git(work, 'push', '-q', 'origin', 'issue/7-x');
git(work, 'checkout', '-q', 'dev');
// gh: тело issue — фиксированное, сеть не нужна.
writeFileSync(join(root, 'body.md'), BODY);
writeFileSync(join(bin, 'gh'), `#!/usr/bin/env bash\ncat '${join(root, 'body.md')}'\n`);
chmodSync(join(bin, 'gh'), 0o755);
const env = { ...GIT_ENV, RUNNER_TEMP: temp, PATH: `${bin}:${process.env.PATH}` };
const run = (body, extra) => {
const output = join(temp, `output-${Math.random().toString(36).slice(2)}`);
const summary = join(temp, 'summary.md');
// Шаг без `shell:` GitHub исполняет как `bash -e {0}`.
const r = spawnSync('bash', ['--noprofile', '--norc', '-e', '-c', body], {
cwd: work, encoding: 'utf8', env: { ...env, GITHUB_OUTPUT: output, GITHUB_STEP_SUMMARY: summary, ...extra },
});
let out = '';
try { out = readFileSync(output, 'utf8'); } catch { /* шаг ничего не записал */ }
return { ...r, out };
};
return { root, work, temp, git, run };
}
const outputOf = (out, key) => out.match(new RegExp(`^${key}=(.*)$`, 'm'))?.[1];
test('#765 AC2: шаги prepare на ветке, отставшей от dev и подменившей review-doc-guard.mjs, исполняют dev', (t) => {
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const fx = fixture(t);
const steps = prepareSteps();
const tools = fx.run(named(steps, TOOLS_STEP).run);
assert.equal(tools.status, 0, tools.stderr);
const dir = outputOf(tools.out, 'dir');
const sha = outputOf(tools.out, 'sha');
assert.equal(sha, fx.git(fx.work, 'rev-parse', 'origin/dev'), 'закреплён SHA dev');
assert.ok(!dir.startsWith(fx.work), 'снимок вне рабочей копии');
// «Перейти на ветку задачи»: рабочая копия становится материалом.
fx.git(fx.work, 'checkout', '-q', 'origin/issue/7-x');
const env = { TOOLS: dir, NUM: '7', REPO: 'o/r', GH_TOKEN: 'x' };
const material = fx.run(named(steps, 'Зафиксировать SHA материала ревью').run, env);
assert.equal(material.status, 0, material.stderr);
assert.equal(outputOf(material.out, 'sha'), fx.git(fx.work, 'rev-parse', 'HEAD'), 'якорь — материал, а не снимок');
assert.equal(outputOf(material.out, 'issue_body'), issueBodyDigest(BODY), 'хеш тела — функцией dev, не ветки');
const reuse = fx.run(named(steps, 'Зелёный вердикт прошлого захода применим без ревью (#499)').run,
{ ...env, ISSUE_BODY: issueBodyDigest(BODY) });
assert.equal(reuse.status, 0, reuse.stderr);
assert.equal(outputOf(reuse.out, 'reuse'), 'false', 'подменённый скрипт выдал бы себе reuse=true — ревью без модели');
const spec = fx.run(named(steps, 'ТЗ менялось после зелёного ревью ТЗ (#517)').run,
{ ...env, DIGEST: issueBodyDigest(BODY) });
assert.equal(spec.status, 0, spec.stderr);
assert.equal(outputOf(spec.out, 'changed'), 'false', 'подменённый скрипт объявил бы ТЗ изменившимся');
});
test('#765 AC3: расход снимается скриптом закреплённого SHA — на ветке без model-usage.mjs и после сдвига dev', (t) => {
if (!hasTools()) { t.skip('bash/tar/git недоступны'); return; }
const fx = fixture(t);
const tools = fx.run(named(prepareSteps(), TOOLS_STEP).run);
assert.equal(tools.status, 0, tools.stderr);
const sha = outputOf(tools.out, 'sha');
// dev двинулся после подготовки: новая версия скрипта расхода печатает чужое.
fx.git(fx.work, 'checkout', '-q', 'dev');
writeFileSync(join(fx.work, 'scripts', 'model-usage.mjs'), "process.stdout.write('позже\\n');\n");
fx.git(fx.work, 'add', '-A');
fx.git(fx.work, 'commit', '-q', '-m', 'dev позже');
fx.git(fx.work, 'push', '-q', 'origin', 'dev');
// model_review: рабочая копия — материал, model-usage.mjs в нём нет.
fx.git(fx.work, 'checkout', '-q', 'origin/issue/7-x');
const exec = join(fx.temp, 'execution.json');
writeFileSync(exec, JSON.stringify([{ type: 'system' }, { type: 'result', usage: USAGE, num_turns: USAGE.num_turns }]));
const body = named(modelSteps(), USAGE_STEP).run;
const usage = fx.run(body, { EXEC: exec, TOOLS_SHA: sha });
assert.equal(usage.status, 0, usage.stderr);
assert.equal(outputOf(usage.out, 'line'), formatUsage(USAGE), 'строка данных, а не missing и не версия позже');
// Нет SHA из prepare — громкий сбой отчётного шага, а не молчаливая строка.
const lost = fx.run(body, { EXEC: exec, TOOLS_SHA: '' });
assert.notEqual(lost.status, 0);
assert.match(lost.stdout + lost.stderr, /нет SHA снимка/);
});
+1 -1
View File
@@ -101,7 +101,7 @@ test('#636 workflows: prepare exits pending with a sealed marker, resume relabel
// #623: триггер — у тонкого вызывающего файла, тело — в `_process-resume.yml`.
const resumeCaller = readFileSync(new URL('../.github/workflows/process-resume.yml', import.meta.url), 'utf8');
const validate = readFileSync(new URL('../.github/workflows/validate.yml', import.meta.url), 'utf8');
assert.match(process, /validate-gate\.mjs --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA" --no-wait/);
assert.match(process, /node "\$TOOLS\/scripts\/validate-gate\.mjs" --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA" --no-wait/);
assert.match(process, /2\) echo 'proceed=pending' >> "\$GITHUB_OUTPUT"/);
assert.match(process, /review-pending-\$\{NUM\}-\$\{GITHUB_RUN_ID\}-\$\{GITHUB_RUN_ATTEMPT\}/);
assert.match(process, /sha256sum pending\.json > manifest\.sha256/);
+13 -6
View File
@@ -137,10 +137,12 @@ test('конвейер: трек снимается до ребейза, мут
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 не ребейзится');
// #765: скрипт — из снимка dev подготовки: ветка show/ship не ребейзится.
assert.match(trackStep, /TOOLS: \$\{\{ steps\.tools\.outputs\.dir \}\}/, 'скрипт — из снимка dev: ветка show/ship не ребейзится');
assert.doesNotMatch(trackStep, /git archive/, 'своего извлечения у шага нет — один снимок на заход');
// #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, /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/, 'выход за рамки повышает трек');
@@ -652,6 +654,8 @@ const TRACK_STEP = ' - name: "Трек задачи и рамки ship (#69
const GUARD_STEP = ' - id: decide\n';
const DECIDE_STEP = ' - name: Решение по вердикту\n';
const TOOLS_STEP = ' - name: Скрипты конвейера — из dev (#749)\n';
// #765: снимок подготовки — шаги prepare (трек, ребейз) зовут скрипты из него.
const PREPARE_TOOLS_STEP = ' - name: Скрипты конвейера — из dev (#765)\n';
const PUBLISH_STEP = ' - name: Опубликовать документ ревью\n';
test('#707 AC4: изменённые run шага трека, guard и решения по вердикту проходят bash -n', async (t) => {
@@ -785,7 +789,7 @@ function trackSandbox(t, { change, base = () => {} }) {
].join('\n'), { mode: 0o755 });
const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : '');
let tools = '';
return {
const box = {
work, fake,
run(script, env) {
for (const name of ['gh-calls', 'comment.md']) rmSync(join(fake, name), { force: true });
@@ -811,15 +815,18 @@ function trackSandbox(t, { change, base = () => {} }) {
if (list === null) rmSync(join(fake, 'labels'), { force: true });
else writeFileSync(join(fake, 'labels'), `${list.join(',')}\n`);
},
/** #749: шаг снимка job integrate как есть; его каталог дальше идёт шагам как TOOLS. */
snapshot() {
const r = this.run(stepRun(readFileSync(WORKFLOW, 'utf8'), TOOLS_STEP), {});
/** #749/#765: шаг снимка job как есть; его каталог дальше идёт шагам как TOOLS. */
snapshot(step = TOOLS_STEP) {
const r = this.run(stepRun(readFileSync(WORKFLOW, 'utf8'), step), {});
assert.equal(r.status, 0, `снимок скриптов dev: ${r.stderr}`);
assert.ok(r.output.dir && existsSync(join(r.output.dir, 'scripts', 'process-track.mjs')), 'снимок несёт скрипт трека');
tools = r.output.dir;
return tools;
},
};
// #765: в prepare снимок dev идёт до перехода на материал и до трека.
box.snapshot(PREPARE_TOOLS_STEP);
return box;
}
const touchChange = (work) => writeFileSync(join(work, 'src', 'pointer-modality.ts'),
+12 -6
View File
@@ -211,8 +211,10 @@ test('#643 process.yml: шаг «Привести ветку к dev» ребей
const step = rebaseStep();
assert.ok(step.length > 0, 'шаг найден');
assert.doesNotMatch(step, /^\s+if ! git rebase origin\/dev/m, 'голого git rebase больше нет');
assert.match(step, /git archive origin\/dev scripts \| tar -x -C "\$tools"/, 'помощник — из dev, не из отставшей ветки');
assert.match(step, /files=\$\(node "\$tools\/scripts\/rebase-generated\.mjs" --onto=origin\/dev\) \|\| code=\$\?/);
// #765: помощник — из снимка dev подготовки, не из отставшей ветки.
assert.match(step, /TOOLS: \$\{\{ steps\.tools\.outputs\.dir \}\}/, 'помощник — из снимка dev, не из отставшей ветки');
assert.doesNotMatch(step, /git archive/, 'своего извлечения нет — один снимок на заход');
assert.match(step, /files=\$\(node "\$TOOLS\/scripts\/rebase-generated\.mjs" --onto=origin\/dev\) \|\| code=\$\?/);
assert.match(step, /if \[ "\$code" -ne 0 \] && \[ "\$code" -ne 3 \]; then/, 'сбой помощника — не конфликт');
assert.match(step, /echo 'conflict=true'\n\s+echo 'conflicts<<EOF_FILES'/, 'выход conflict/conflicts на месте');
// Порядок: ребейз → push с lease → ожидание ссылки. Индекс в ветке задачи
@@ -237,16 +239,20 @@ function runStepRebase(work) {
const step = rebaseStep();
const body = step.slice(step.indexOf(' run: |\n') + ' run: |\n'.length)
.split('\n').map((line) => line.replace(/^ {10}/, '')).join('\n');
const from = body.indexOf('tools="$RUNNER_TEMP/rebase-tools"');
const from = body.indexOf('code=0\nfiles=$(node "$TOOLS/scripts/rebase-generated.mjs"');
const to = body.indexOf('# #657 (1б): индекс ревью в ветке');
assert.ok(from >= 0 && to > from, 'ребейзная часть шага найдена');
const temp = mkdtempSync(join(tmpdir(), 'hp-runner-'));
try {
const output = join(temp, 'output');
writeFileSync(output, '');
// #765: снимок dev подготовки (его шаг исполняет process-prepare-tools.test.mjs).
const tools = join(temp, 'dev-tools');
mkdirSync(tools);
execFileSync('bash', ['-eo', 'pipefail', '-c', `git archive origin/dev scripts | tar -x -C "${tools}"`], { cwd: work, env: ENV });
const script = `${body.slice(from, to)}\necho REBASED\n`;
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
cwd: work, encoding: 'utf8', env: { ...ENV, RUNNER_TEMP: temp, GITHUB_OUTPUT: output, BRANCH: 'issue/9-fix' },
cwd: work, encoding: 'utf8', env: { ...ENV, RUNNER_TEMP: temp, GITHUB_OUTPUT: output, BRANCH: 'issue/9-fix', TOOLS: tools },
});
return { status: r.status, stdout: r.stdout, stderr: r.stderr, output: readFileSync(output, 'utf8') };
} finally {
@@ -404,7 +410,7 @@ function runStepPush(pushStderr) {
// #730: сводка шага, куда код слияния пишет причину отказа.
const summary = join(temp, 'summary.md');
writeFileSync(summary, '');
const script = `tools=${JSON.stringify(resolve(SCRIPTS, '..'))}\nbefore=${'b'.repeat(40)}\n${block}\necho PUSHED\n`;
const script = `TOOLS=${JSON.stringify(resolve(SCRIPTS, '..'))}\nbefore=${'b'.repeat(40)}\n${block}\necho PUSHED\n`;
const r = spawnSync('bash', ['--noprofile', '--norc', '-eo', 'pipefail', '-c', script], {
encoding: 'utf8',
env: {
@@ -469,7 +475,7 @@ test('#705 process.yml: отказ по праву на workflow возвращ
const at = (marker) => { const i = WORKFLOW.indexOf(marker); assert.ok(i > 0, `нет «${marker}»`); return i; };
const step = rebaseStep();
assert.match(step, /"HEAD:refs\/heads\/\$BRANCH" 2> "\$push_err"; then/, 'stderr push идёт в разбор, а не мимо');
assert.match(step, /kind=\$\(node "\$tools\/scripts\/merge-candidate\.mjs" --push-refusal="\$push_err"/, 'разбор — кодом слияния из dev');
assert.match(step, /kind=\$\(node "\$TOOLS\/scripts\/merge-candidate\.mjs" --push-refusal="\$push_err"/, 'разбор — кодом слияния из снимка dev (#765)');
const back = WORKFLOW.slice(at(' - name: "Push ребейза отклонён по праву на workflow — вернуть автору без ревью (#705)"\n'),
at(' - name: Validate на материале\n'));
assert.match(back, /if: steps\.rebase\.outputs\.refused == 'workflow'\n/);
+16 -3
View File
@@ -179,6 +179,18 @@ test('повторная приписка заменяет блок, а не к
assert.match(twice, /# отчёт/);
});
test('#765: повторная приписка идемпотентна — разделитель перед блоком не копится', () => {
const anchors = { sha: 'a'.repeat(40), tree: 'b'.repeat(40), branch: 'dev', specs: [] };
const once = withMaterialAnchors('# отчёт\n\nтекст\n', anchors);
const thrice = withMaterialAnchors(withMaterialAnchors(once, anchors), anchors);
assert.equal(thrice, once);
assert.equal(thrice.match(/^---$/gm).length, 1, 'один разделитель');
// Разделитель самого документа — не наш: он остаётся.
const own = withMaterialAnchors('# отчёт\n\n---\n\nвывод\n', anchors);
assert.equal(withMaterialAnchors(own, anchors), own);
assert.match(own, /---\n\nвывод\n\n---\n\n<!-- material-anchors/);
});
test('список ТЗ разбирается и отсекает мусор (#414)', () => {
const parsed = parseSpecList(
`${'a'.repeat(40)} docs/specs/403-x.md;короткий docs/specs/y.md;${'b'.repeat(40)} ;`,
@@ -647,7 +659,8 @@ test('конвейер: посторонняя метка не входит в c
test('конвейер: зелёный вердикт применяется повторно без модели, вердикт пишется в якоря (#499)', () => {
const workflow = readFileSync(new URL('../.github/workflows/_process.yml', import.meta.url), 'utf8');
assert.match(workflow, /review-doc-guard\.mjs --reuse --marker=CODE-REVIEW --num="\$NUM" --head=HEAD/);
assert.match(workflow, /node "\$TOOLS\/scripts\/review-doc-guard\.mjs" --reuse --marker=CODE-REVIEW --num="\$NUM" --head=HEAD/,
'reuse решает скрипт снимка dev, не ветки (#765)');
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'/,
'при повторном применении вся стадия модели пропускается');
@@ -673,7 +686,7 @@ test('#510 AC2: конвейер запускает Validate с мутантам
assert.ok(material < gate && gate < back && back < modelJob && modelJob < deps && deps < review,
'гейт закончен в отдельной стадии до установки зависимостей/ревью');
const gateStep = workflow.slice(gate, back);
assert.match(gateStep, /node scripts\/validate-gate\.mjs --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA"/);
assert.match(gateStep, /node "\$TOOLS\/scripts\/validate-gate\.mjs" --repo="\$\{\{ github\.repository \}\}" --ref="\$BRANCH" --sha="\$SHA"/);
assert.match(gateStep, /if \[ "\$STAGE" != "code" \] \|\| \[ "\$REUSE" = "true" \]/, 'этап spec и reuse гейт не проходят');
assert.match(gateStep, /SHA: \$\{\{ steps\.material\.outputs\.sha \}\}/, 'проверяется именно материал');
// skip-ветка (spec/reuse) даёт proceed=true: ревью идёт, возврата S7→S6 нет (ревью ТЗ r2)
@@ -865,7 +878,7 @@ test('#551: gates, модель и интеграция имеют незави
assert.match(prepare, /timeout-minutes: 55/);
assert.match(model, /timeout-minutes: 45/);
assert.match(integrate, /timeout-minutes: 55/);
assert.match(prepare, /node scripts\/validate-gate\.mjs/);
assert.match(prepare, /node "\$TOOLS\/scripts\/validate-gate\.mjs"/);
assert.doesNotMatch(model, /validate-gate\.mjs/, 'ожидания Validate нет в бюджете модели');
assert.match(model, /needs: \[guard, prepare\]/);
assert.match(integrate, /needs: \[guard, prepare, model_review\]/);