diff --git a/.github/workflows/_process.yml b/.github/workflows/_process.yml index 0cea6b26..fb402e5a 100644 --- a/.github/workflows/_process.yml +++ b/.github/workflows/_process.yml @@ -331,6 +331,9 @@ jobs: spec_body_doc: ${{ steps.spec_body.outputs.doc }} spec_body_recorded: ${{ steps.spec_body.outputs.recorded }} duration_seconds: ${{ steps.duration.outputs.seconds }} + # #765: SHA снимка скриптов конвейера — одна версия на заход: model_review + # снимает расход скриптом из того же коммита dev. + tools_sha: ${{ steps.tools.outputs.sha }} track: ${{ steps.track.outputs.track }} mutants: ${{ steps.track.outputs.mutants }} full: ${{ steps.track.outputs.full }} @@ -383,6 +386,29 @@ jobs: node-version: 22 cache: npm + # #765 (продолжение #749): тело читается из dev (@dev, #623), значит, и + # управляющие скрипты подготовки — из dev. Рабочая копия после «Перейти на + # ветку задачи» — материал: ветка show/ship с чистым слиянием до ревью не + # ребейзится (§10.4) и может нести scripts/, отставшие на дни или + # подменённые. Раньше якорь тела issue, reuse (#499), Validate-gate (#510) + # и проверку ТЗ (#517) исполняли скрипты ветки — материал сам участвовал в + # решении о своём допуске к ревью. Снимок один на заход: SHA закрепляется + # здесь, до любого git fetch трека и ребейза, и выходом `tools_sha` уходит + # в model_review. validate.yml в снимке — по той же причине, что в #749: + # ci-proof.mjs (validate-gate) читает его по пути от себя. Git, дифф и + # документы по-прежнему судятся в рабочей копии — снимок только исполняет. + - name: Скрипты конвейера — из dev (#765) + id: tools + run: | + set -euo pipefail + git fetch -q origin dev + sha=$(git rev-parse origin/dev) + tools="$RUNNER_TEMP/dev-tools" + rm -rf "$tools" && mkdir -p "$tools" + git archive "$sha" scripts .github/workflows/validate.yml | tar -x -C "$tools" + { echo "dir=$tools"; echo "sha=$sha"; } >> "$GITHUB_OUTPUT" + echo "скрипты конвейера: dev ${sha:0:12}" + # Материал ревью живёт в ветке задачи: ТЗ в docs/specs/ и код коммитятся # в issue/-slug. Если ветка запушена — переключаемся на неё, иначе # ревьюер прочтёт dev и не найдёт того, что должен оценивать. @@ -423,9 +449,9 @@ jobs: # ОДИН вызов process-track.mjs по меткам, диффу и комментариям; bash только # исполняет: пишет комментарий из файла скрипта и меняет метки по его # флагу. Выходы (`track`, `mutants`, `full`, `ship`, `risk_note`, - # `ship_risk`) скрипт пишет в GITHUB_OUTPUT сам. Скрипт берётся из dev, как - # помощник ребейза: show/ship до ревью не ребейзятся, и ветка, отставшая от - # dev, его может ещё не нести. + # `ship_risk`) скрипт пишет в GITHUB_OUTPUT сам. Скрипт — из снимка dev + # (#765): show/ship до ревью не ребейзятся, и ветка, отставшая от dev, его + # может ещё не нести. - name: "Трек задачи и рамки ship (#696)" id: track env: @@ -435,17 +461,15 @@ jobs: LABELS: ${{ needs.guard.outputs.labels }} BRANCH: ${{ steps.branch.outputs.name }} OWNER: ${{ github.repository_owner }} + TOOLS: ${{ steps.tools.outputs.dir }} run: | git fetch -q origin dev - tools="$RUNNER_TEMP/track-tools" - rm -rf "$tools" && mkdir -p "$tools" - git archive origin/dev scripts | tar -x -C "$tools" # Комментарии — для строки владельца «Трек: — решение владельца» # (#707). Не прочитаны — пустой файл: происхождение не установлено. comments="$RUNNER_TEMP/track-comments.json" gh issue view "$NUM" --repo "${{ github.repository }}" --json comments > "$comments" 2>/dev/null \ || : > "$comments" - out=$(node "$tools/scripts/process-track.mjs" stage --stage="$STAGE" --labels="$LABELS" \ + out=$(node "$TOOLS/scripts/process-track.mjs" stage --stage="$STAGE" --labels="$LABELS" \ --branch="$BRANCH" --base=origin/dev --head=HEAD --comments="$comments" --owner="$OWNER" \ --out="$RUNNER_TEMP/track" \ --run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}") @@ -479,6 +503,7 @@ jobs: BRANCH: ${{ steps.branch.outputs.name }} NUM: ${{ github.event.issue.number }} TRACK: ${{ steps.track.outputs.track }} + TOOLS: ${{ steps.tools.outputs.dir }} # rebase, в отличие от commit, не принимает -c user.*: он запускает # свои процессы и требует личность в окружении, иначе падает с # «unable to auto-detect email address». @@ -515,13 +540,10 @@ jobs: # сдвиге dev. Помощник ребейза пересобирает индекс по каталогу, если # ВСЕ конфликты остановки — индекс, и отказывает (с abort) на любом # другом. Список конфликтов он снимает ДО abort (#364) и печатает в - # stdout по строке. Помощник берётся из dev, а не из ветки: ветка, - # отставшая от dev, его ещё не несёт. - tools="$RUNNER_TEMP/rebase-tools" - rm -rf "$tools" && mkdir -p "$tools" - git archive origin/dev scripts | tar -x -C "$tools" + # stdout по строке. Помощник — из снимка dev (#765), а не из ветки: + # ветка, отставшая от dev, его ещё не несёт. code=0 - files=$(node "$tools/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$? + files=$(node "$TOOLS/scripts/rebase-generated.mjs" --onto=origin/dev) || code=$? # Код 3 — отказ с перечнем (ребейз отменён); любой другой ненулевой — # сбой самого помощника, конфликтом ветки он не выдаётся. if [ "$code" -ne 0 ] && [ "$code" -ne 3 ]; then @@ -564,7 +586,7 @@ jobs: "https://x-access-token:$TOKEN@github.com/${{ github.repository }}" \ "HEAD:refs/heads/$BRANCH" 2> "$push_err"; then refusal="$RUNNER_TEMP/push-refusal.md" - kind=$(node "$tools/scripts/merge-candidate.mjs" --push-refusal="$push_err" \ + kind=$(node "$TOOLS/scripts/merge-candidate.mjs" --push-refusal="$push_err" \ --branch="$BRANCH" --candidate="$(git rev-parse HEAD)" --stage=rebase \ --run-url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ --comment="$refusal" --summary="$GITHUB_STEP_SUMMARY") || kind=unknown @@ -621,6 +643,7 @@ jobs: NUM: ${{ github.event.issue.number }} REPO: ${{ github.repository }} GH_TOKEN: ${{ secrets.HP_PROCESS_TOKEN }} + TOOLS: ${{ steps.tools.outputs.dir }} run: | echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" # Якоря материала, устойчивые к ребейзу (#413, #414). SHA коммита @@ -646,7 +669,8 @@ jobs: body=$(mktemp) if gh issue view "$NUM" --repo "$REPO" --json body --jq .body > "$body"; then digest=$(node -e ' - import("./scripts/review-doc-guard.mjs").then(async (m) => { + const { pathToFileURL } = require("node:url"); + import(pathToFileURL(`${process.env.TOOLS}/scripts/review-doc-guard.mjs`).href).then(async (m) => { const { readFileSync } = await import("node:fs"); process.stdout.write(m.issueBodyDigest(readFileSync(process.argv[1], "utf8"))); }); @@ -676,8 +700,9 @@ jobs: # зелёного вердикта: иначе вызов модели пропускается и находку # «ТЗ менялось» некому напечатать (#517 AC6). ISSUE_BODY: ${{ steps.material.outputs.issue_body }} + TOOLS: ${{ steps.tools.outputs.dir }} run: | - out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \ + out=$(node "$TOOLS/scripts/review-doc-guard.mjs" --reuse --marker=CODE-REVIEW --num="$NUM" --head=HEAD \ --issue-body="${ISSUE_BODY}") printf '%s\n' "$out" printf '%s\n' "$out" >> "$GITHUB_OUTPUT" @@ -757,6 +782,7 @@ jobs: MUTANTS: ${{ steps.track.outputs.mutants }} # #697: `ci:full`/`ci:golden` — полный набор на материале. FULL: ${{ steps.track.outputs.full }} + TOOLS: ${{ steps.tools.outputs.dir }} run: | if [ "$STAGE" != "code" ] || [ "$REUSE" = "true" ] || [ -z "$BRANCH" ]; then echo "гейт не применяется: этап $STAGE, reuse=${REUSE:-false}, ветка ${BRANCH:-dev}" @@ -770,7 +796,7 @@ jobs: # S7), страховка — process-reconcile. Зелёный или красный завершённый # прогон гейт и без ожидания возвращает сразу. set +e - node scripts/validate-gate.mjs --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \ + node "$TOOLS/scripts/validate-gate.mjs" --repo="${{ github.repository }}" --ref="$BRANCH" --sha="$SHA" --no-wait \ --mutants="${MUTANTS:-false}" --full="${FULL:-false}" code=$? set -e @@ -899,13 +925,15 @@ jobs: env: NUM: ${{ github.event.issue.number }} DIGEST: ${{ steps.material.outputs.issue_body }} + TOOLS: ${{ steps.tools.outputs.dir }} run: | if [ -z "$DIGEST" ]; then echo "хеша тела нет — сравнивать не с чем" exit 0 fi out=$(node -e ' - import("./scripts/review-doc-guard.mjs").then(async (m) => { + const { pathToFileURL } = require("node:url"); + import(pathToFileURL(`${process.env.TOOLS}/scripts/review-doc-guard.mjs`).href).then(async (m) => { const { execFileSync } = await import("node:child_process"); const [num, digest] = process.argv.slice(1); const git = (args) => { try { return execFileSync("git", args, { encoding: "utf8" }); } catch { return ""; } }; @@ -1312,14 +1340,33 @@ jobs: # execution_file — все сообщения сессии, с результатами инструментов — # не выгружается и не печатается: скрипт берёт из него только `result`. # `always()` — расход упавшей сессии хотя бы виден в сводке. + # + # #765: рабочая копия здесь — материал ревью. На отставшей ветке show/ship + # model-usage.mjs нет, и публикация молча писала `reason=missing`; ветка + # могла и подменить скрипт. Скрипт берётся из того же коммита dev, что и + # снимок подготовки (`tools_sha`). Снимок извлекается здесь, а не отдельным + # шагом до Review: его сбой — сбой отчётного шага (continue-on-error), а не + # всей стадии. От сессии модели, исполнявшейся на этом раннере, снимок не + # защищает — поэтому строка остаётся недоверенным вводом и разбирается + # строго при публикации (#556, #737). - name: Снять расход модели id: usage if: always() continue-on-error: true env: EXEC: ${{ steps.review.outputs.execution_file }} + TOOLS_SHA: ${{ needs.prepare.outputs.tools_sha }} run: | - line=$(node scripts/model-usage.mjs --execution-file="$EXEC") + set -euo pipefail + if [ -z "$TOOLS_SHA" ]; then + echo "::error::нет SHA снимка скриптов из prepare — расход не снят" + exit 1 + fi + tools="$RUNNER_TEMP/dev-tools" + rm -rf "$tools" && mkdir -p "$tools" + git cat-file -e "${TOOLS_SHA}^{commit}" 2>/dev/null || git fetch -q origin "$TOOLS_SHA" + git archive "$TOOLS_SHA" scripts | tar -x -C "$tools" + line=$(node "$tools/scripts/model-usage.mjs" --execution-file="$EXEC") echo "line=$line" >> "$GITHUB_OUTPUT" echo "- расход модели: \`$line\`" >> "$GITHUB_STEP_SUMMARY" diff --git a/PROCESS.md b/PROCESS.md index 5cb0d956..3929d890 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -1327,16 +1327,27 @@ Validate запускает `ship-review.yml` dispatch'ем с `-f tag=nightly` origin/dev scripts .github/workflows/validate.yml` и зовёт из него каждый repo-скрипт: `review-result-gate`, `review-doc-guard`, `reviews-index`, `merge-candidate`, `process-track route`, `status-label`. Снимок один на job — -одна версия на весь заход, а не смесь по шагам. Так же уже работают шаг трека -(#707), страж ребейза (#698) и разбор отказа push (#723). Рабочая копия ветки +одна версия на весь заход, а не смесь по шагам. Job `prepare` (#765) делает +то же до перехода на ветку задачи, но архивом **закреплённого** SHA +(`git rev-parse origin/dev` один раз — `git fetch` трека и ребейза его не +сдвигают): из снимка идут шаг трека (#707), страж ребейза (#698) с разбором +отказа push (#723), хеш тела issue для якоря, reuse зелёного +вердикта (#499), Validate-гейт (#510) и проверка изменившегося ТЗ (#517). +Прежде четыре последних исполняли скрипты ветки — материал сам решал, допущен +ли он к ревью: подменённый `review-doc-guard.mjs` выдавал себе `reuse=true`. +SHA уходит выходом `tools_sha` в `model_review`. Рабочая копия ветки задачи — только материал: git-команды, документ и пути судятся в ней. Ветка `show`/`ship` с чистым слиянием до ревью не ребейзится и может нести `scripts/`, отставшие от `dev` на дни: её `review-doc-guard.mjs` молча проглотил бы неизвестный флаг, её `merge-candidate.mjs` слил бы по-старому. `validate.yml` в снимке обязателен: `workflow-jobs.mjs` читает его по пути от -себя, без него доказательство Validate кандидата — `failed (#622)`. Исключение -— job `model_review`: ревьюер исполняет тесты и скрипты материала, это его -работа, а не конвейера. Следствие для задач, меняющих сам конвейер: их +себя, без него доказательство Validate кандидата — `failed (#622)`. В job +`model_review` скрипты материала исполняет ревьюер — это его работа, а не +конвейера; детерминированный шаг «Снять расход модели» (#737) — работа +конвейера, и `model-usage.mjs` он берёт архивом `tools_sha` (#765): на +отставшей ветке скрипта нет, и публикация молча писала `reason=missing`. От +сессии модели на том же раннере снимок не защищает, поэтому строка расхода +по-прежнему недоверенный ввод и разбирается строго. Следствие для задач, меняющих сам конвейер: их слияние судит версия `dev`, новая начинает действовать со следующей задачи. Правка контракта доказательства Validate (имена потребляемых job `validate.yml`, политики `ci-proof.mjs`, #622) пишется совместимой с @@ -1470,7 +1481,8 @@ merge. Timeout/cancel/failure называет конкретную стадию **Расход модели** (#737). Шаг `Review` отдаёт `execution_file` — все сообщения сессии SDK, а журнал Actions расход намеренно прячет. Шаг «Снять расход модели» -сразу после `Review` (`if: always()`, `continue-on-error`) берёт из файла только +сразу после `Review` (`if: always()`, `continue-on-error`) — шаг конвейера, а не +ревьюера, со скриптом из снимка `dev` (#765) — берёт из файла только последнее сообщение `result` (`scripts/model-usage.mjs`) и отдаёт одну строку выходом job `usage`: `` — суммы diff --git a/scripts/mutation-registry.mjs b/scripts/mutation-registry.mjs index a304dacf..458cae08 100644 --- a/scripts/mutation-registry.mjs +++ b/scripts/mutation-registry.mjs @@ -14201,6 +14201,39 @@ const MUTANT_DEFINITIONS = [ replace: ' const key = `${spaceId}|`; // mutant: no content fingerprint\n', }], }, + { + id: 'prepare-reuse-runs-material-script', + guard: 'node --test --test-name-pattern="#765 AC2" test/process-prepare-tools.test.mjs', + because: '#765: reuse зелёного вердикта (#499) решает скрипт снимка dev; скрипт рабочей копии — ' + + 'материал задачи, и подменённый review-doc-guard.mjs выдаёт себе reuse=true — слияние без модели', + patches: [{ + file: '.github/workflows/_process.yml', + find: ' out=$(node "$TOOLS/scripts/review-doc-guard.mjs" --reuse --marker=CODE-REVIEW', + replace: ' out=$(node scripts/review-doc-guard.mjs --reuse --marker=CODE-REVIEW', + }], + }, + { + id: 'usage-script-from-moving-dev', + guard: 'node --test --test-name-pattern="#765 AC3" test/process-prepare-tools.test.mjs', + because: '#765: расход снимается скриптом того коммита dev, что закрепила подготовка; архив ' + + 'подвижной origin/dev после сдвига dev исполняет другую версию — смесь версий в одном заходе', + patches: [{ + file: '.github/workflows/_process.yml', + find: ' git archive "$TOOLS_SHA" scripts | tar -x -C "$tools"', + replace: ' git fetch -q origin dev; git archive origin/dev scripts | tar -x -C "$tools"', + }], + }, + { + id: 'material-anchors-pile-separators', + guard: 'node --test --test-name-pattern="#765" test/review-doc-guard.test.mjs', + because: '#765: повторная приписка блока якорей снимает и свой разделитель; без этого каждый ' + + 'повтор копит ещё один `---` перед блоком', + patches: [{ + file: 'scripts/review-doc-guard.mjs', + find: " ? body.slice(0, at).replace(/(?:\\s*\\n---)*\\s*$/, '')", + replace: " ? body.slice(0, at).replace(/\\s+$/, '')", + }], + }, ]; const mutationCardSource = readFileSync(join(repoRoot, 'src/houseplan-card.ts'), 'utf8'); diff --git a/scripts/review-doc-guard.mjs b/scripts/review-doc-guard.mjs index b2410abe..dc036baf 100644 --- a/scripts/review-doc-guard.mjs +++ b/scripts/review-doc-guard.mjs @@ -388,11 +388,17 @@ export function parseSpecList(raw) { .filter((item) => /^[0-9a-f]{40}$/.test(item.blob) && item.path); } -/** Дописать или заменить блок якорей в тексте документа. */ +/** + * Дописать или заменить блок якорей в тексте документа. Идемпотентно (#765): + * при замене снимается и разделитель `---`, который поставила прошлая + * приписка, — иначе каждый повтор копил бы ещё один перед блоком. + */ export function withMaterialAnchors(text, anchors) { const body = String(text ?? ''); const at = body.indexOf(ANCHOR_MARKER); - const head = at >= 0 ? body.slice(0, at).replace(/\s+$/, '') : body.replace(/\s+$/, ''); + const head = at >= 0 + ? body.slice(0, at).replace(/(?:\s*\n---)*\s*$/, '') + : body.replace(/\s+$/, ''); return `${head}\n\n---\n\n${materialAnchorBlock(anchors)}`; } diff --git a/test/model-usage.test.mjs b/test/model-usage.test.mjs index 0a88210f..bad5996e 100644 --- a/test/model-usage.test.mjs +++ b/test/model-usage.test.mjs @@ -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); diff --git a/test/process-prepare-tools.test.mjs b/test/process-prepare-tools.test.mjs new file mode 100755 index 00000000..9cdeaab0 --- /dev/null +++ b/test/process-prepare-tools.test.mjs @@ -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: от ` :` до следующей 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 = /(? { + 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 снимка/); +}); diff --git a/test/process-resume.test.mjs b/test/process-resume.test.mjs index cbdc5ea2..fc8d4bbd 100644 --- a/test/process-resume.test.mjs +++ b/test/process-resume.test.mjs @@ -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/); diff --git a/test/process-track.test.mjs b/test/process-track.test.mjs index e7533206..1def6873 100644 --- a/test/process-track.test.mjs +++ b/test/process-track.test.mjs @@ -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'), diff --git a/test/rebase-generated.test.mjs b/test/rebase-generated.test.mjs index c1468e16..2e982c6d 100644 --- a/test/rebase-generated.test.mjs +++ b/test/rebase-generated.test.mjs @@ -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< 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/); diff --git a/test/review-doc-guard.test.mjs b/test/review-doc-guard.test.mjs index b25eefc9..b7924349 100644 --- a/test/review-doc-guard.test.mjs +++ b/test/review-doc-guard.test.mjs @@ -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