Files
houseplan-card/test/process-prepare-tools.test.mjs
T
claude[bot] 23f822475b 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
2026-10-02 10:18:29 +03:00

277 lines
16 KiB
JavaScript
Executable File
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.
// #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 снимка/);
});