@7n/rules 1.49.15 → 1.49.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.49.17] - 2026-07-27
4
+
5
+ ### Fixed
6
+
7
+ - Keep git-reconcile progress live and PR readiness fail-closed
8
+
9
+ ## [1.49.16] - 2026-07-27
10
+
11
+ ### Fixed
12
+
13
+ - Обмежено npm-module репозиторіями з npm publisher topology
14
+
3
15
  ## [1.49.15] - 2026-07-27
4
16
 
5
17
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.49.15",
3
+ "version": "1.49.17",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: run-detectors.mjs
4
4
  resource: npm/scripts/lib/lint-surface/run-detectors.mjs
5
5
  docgen:
6
- crc: e9224e37
6
+ crc: 9f044303
7
7
  model: openai-codex/gpt-5.4-mini
8
8
  tier: cloud-min
9
9
  score: 100
@@ -19,7 +19,7 @@ docgen:
19
19
 
20
20
  DEFAULT_RULES_DIR задає базовий корінь правил, від якого стартує discovery, коли споживач не передав власні каталоги.
21
21
 
22
- buildDetectPlan спочатку визначає набір rules-каталогів, потім відбирає лише доступні concern-и з урахуванням capability, далі будує план виконання за режимом прогону: scoped, delta, full або repo-wide. Сам план фіксує, які concern-и запускаються whole-repo, а які лише по перетину з файлами, щоб detect і fix-pipeline працювали з однаковою картиною.
22
+ buildDetectPlan спочатку визначає набір rules-каталогів, відбирає concern-и за capability і застосовує опційний rule-level `applies` gate. Лише після цього він будує план виконання за режимом прогону: scoped, delta, full або repo-wide. Сам план фіксує, які concern-и запускаються whole-repo, а які лише по перетину з файлами, щоб detect і fix-pipeline працювали з однаковою картиною.
23
23
 
24
24
  loadEnabledLintRules використовує той самий discovery-ланцюжок, але повертає не план, а повну мапу concern-и за rule-id разом із множиною активних правил. Це потрібно зовнішнім споживачам, які мають знати, що реально доступно для прогону, не запускаючи сам detector-цикл.
25
25
 
@@ -42,6 +42,8 @@ concern тригериться на цих файлах (та сама табл
42
42
  full-scope перевірки — справа `--repo-wide`).
43
43
  - detectAll — Запускає detect-only прохід. Повертає всі violations і похідний exitCode.
44
44
 
45
+ Rule-level `applies` у `<rule>/applies/main.mjs` може повернути `false`, щоб виключити всі concern-и правила з плану для поточного репозиторію.
46
+
45
47
  ## Гарантії поведінки
46
48
 
47
49
  - Власних операцій запису (ФС/БД) у файлі немає; виклики імпортованих модулів можуть писати.
@@ -9,9 +9,10 @@
9
9
  * @typedef {import('../concern-meta.mjs').ConcernMeta} ConcernMeta
10
10
  * @typedef {{ ruleId: string, concern: ConcernMeta }} LintEntry
11
11
  */
12
+ import { existsSync } from 'node:fs'
12
13
  import { dirname, join } from 'node:path'
13
14
  import { env } from 'node:process'
14
- import { fileURLToPath } from 'node:url'
15
+ import { fileURLToPath, pathToFileURL } from 'node:url'
15
16
 
16
17
  import picomatch from 'picomatch'
17
18
 
@@ -126,6 +127,46 @@ async function filterByCapabilities(byRule, opts) {
126
127
  return out
127
128
  }
128
129
 
130
+ /**
131
+ * Застосовує опційний rule-level gate з `<rule>/applies/main.mjs` до всіх
132
+ * concern-ів правила. Це потрібно для доменних правил, чий канон має сенс
133
+ * лише за наявності конкретної topology в репозиторії: один gate не дає
134
+ * policy- й JS-concern-ам розійтися у власних евристиках застосовності.
135
+ *
136
+ * @param {Record<string, ConcernMeta[]>} byRule concerns за rule-id.
137
+ * @param {string} cwd корінь репозиторію, який лінтиться.
138
+ * @returns {Promise<Record<string, ConcernMeta[]>>} лише застосовні правила.
139
+ */
140
+ async function filterByRuleApplies(byRule, cwd) {
141
+ /** @type {Record<string, ConcernMeta[]>} */
142
+ const out = {}
143
+ for (const [ruleId, concerns] of Object.entries(byRule)) {
144
+ const firstConcern = concerns[0]
145
+ if (!firstConcern) continue
146
+ const appliesPath = join(dirname(firstConcern.dir), 'applies', 'main.mjs')
147
+ if (!existsSync(appliesPath)) {
148
+ out[ruleId] = concerns
149
+ continue
150
+ }
151
+ let applies
152
+ try {
153
+ const mod = await import(pathToFileURL(appliesPath).href)
154
+ applies = mod.applies
155
+ } catch (error) {
156
+ throw new Error(`rule ${ruleId}: не вдалося завантажити applies gate: ${error.message}`)
157
+ }
158
+ if (applies === undefined) {
159
+ out[ruleId] = concerns
160
+ continue
161
+ }
162
+ if (typeof applies !== 'function') {
163
+ throw new Error(`rule ${ruleId}: applies/main.mjs має експортувати applies(cwd)`)
164
+ }
165
+ if (await applies(cwd)) out[ruleId] = concerns
166
+ }
167
+ return out
168
+ }
169
+
129
170
  /**
130
171
  * Мердж concerns кількох rules-каталогів: правила зливаються за id, концерни — за іменем
131
172
  * (перший власник виграє: ядро → плагіни у порядку списку). Плагін може ДОДАВАТИ концерни
@@ -267,7 +308,8 @@ export async function buildDetectPlan(opts) {
267
308
  */
268
309
  export async function loadEnabledLintRules(opts) {
269
310
  const rulesDirs = await effectiveRulesDirs(opts)
270
- const byRule = await filterByCapabilities(await readLintConcernsByRuleMulti(rulesDirs), opts)
311
+ const capable = await filterByCapabilities(await readLintConcernsByRuleMulti(rulesDirs), opts)
312
+ const byRule = await filterByRuleApplies(capable, opts.cwd)
271
313
  const enabledSet = new Set(await enabledRuleIds(byRule, opts.cwd, rulesDirs))
272
314
  return { byRule, enabledSet }
273
315
  }
@@ -618,7 +660,8 @@ export async function detectAll(opts) {
618
660
  const baseLog = opts.log ?? (s => process.stdout.write(s))
619
661
 
620
662
  const rulesDirs = await effectiveRulesDirs(opts)
621
- const byRule = await filterByCapabilities(await readLintConcernsByRuleMulti(rulesDirs), opts)
663
+ const capable = await filterByCapabilities(await readLintConcernsByRuleMulti(rulesDirs), opts)
664
+ const byRule = await filterByRuleApplies(capable, cwd)
622
665
  const plan = await buildPlan({
623
666
  byRule,
624
667
  full,
@@ -44,7 +44,8 @@ time без вигаданого total, а `triage`, `PR` і `cleanup` — ок
44
44
  append-only bar snapshots за вже відомими batches/groups/sources. Поточний
45
45
  LLM-етап показує tier `min` або `max`; довгі етапи кожні 30 секунд отримують
46
46
  heartbeat з elapsed time. Формат однаковий у TTY/CI, тому captured output не
47
- містить cursor-control spam.
47
+ містить cursor-control spam. Install, tests, lint і очікування PR checks
48
+ виконуються через non-blocking child processes, тому heartbeat не завмирає.
48
49
 
49
50
  Незалежні PR-групи виконуються з bounded concurrency `3`; override
50
51
  `N_GIT_RECONCILE_CONCURRENCY=1..4`. Порядок фінального звіту лишається
@@ -90,8 +91,14 @@ transport. Після провалу `max` джерело fail-closed лишає
90
91
  - Перед push обов'язково проходять фінальний tree-diff guard, domain lint для
91
92
  non-code paths, changelog і `git diff --check`; code changes додатково
92
93
  проходять scoped docs/lint та tests.
94
+ - Canonical fixers охоплюють code і non-code directories; після механічного
95
+ виправлення фінальні gates обов'язково запускаються повторно без fix.
93
96
  - Behavioral LLM не викликається для змін без code paths; test baseline
94
97
  актуальної policy base branch кешується між PR-групами.
98
+ - Після `gh pr create` JS чекає terminal CI state і порівнює failed checks із
99
+ base commit. Лише `ready` PR дозволяє cleanup; regression, baseline-red,
100
+ timeout, pending або unreadable checks зберігають branch, URL і worktree та
101
+ завершують команду non-zero.
95
102
  - Cleanup виконує лише JS і тільки після inventory/PR-фази: видаляє точні refs,
96
103
  уже merged/patch-equivalent, явно класифіковані як `drop` або повністю
97
104
  перенесені в успішний PR.
@@ -104,6 +111,8 @@ transport. Після провалу `max` джерело fail-closed лишає
104
111
 
105
112
  Оркестратор повертає для кожного джерела один verdict: `merged`,
106
113
  `patch-equivalent`, `open-pr`, `protected`, `pr-created`, `kept`,
107
- `drop-recommended` або `failed`. Для `pr-created` додає URL, перенесені коміти,
108
- конфлікти та виконані перевірки. Для cleanup ref звіт також містить точний OID
109
- і видалені aliases, а для failure branch та збережений worktree.
114
+ `drop-recommended`, `pr-checks-regressed`, `pr-checks-baseline-red`,
115
+ `pr-checks-unverified` або `failed`. `pr-created` означає, що checks завершились
116
+ успішно; для непідтвердженого PR звіт зберігає URL, branch, worktree і точну
117
+ причину. Summary містить точний count кожного outcome. Для cleanup ref звіт
118
+ також містить точний OID і видалені aliases.
@@ -3,70 +3,46 @@ type: JS Module
3
3
  title: orchestrate.mjs
4
4
  resource: npm/skills/git-reconcile/js/orchestrate.mjs
5
5
  docgen:
6
- crc: 6e9cb156
7
- model: openai-codex/gpt-5.5
8
- tier: cloud-avg
6
+ crc: e28dcd28
7
+ model: omlx/gemma-4-e4b-it-OptiQ-4bit
9
8
  score: 100
10
9
  ---
11
10
 
12
11
  ## Огляд
13
12
 
14
- Файл координує Git reconcile-орchestrator: інвентаризує repository refs і worktrees, прибирає дублікати refs, визначає conflict files, формує triage prompt, перевіряє decision envelope та застосовує рішення через runner. Він існує, щоб переносити cleanup у перевірюваний процес із behavior baseline, cached baseline у межах прогону, validation gates, контрольованим concurrency і детермінованим Markdown-звітом.
13
+ Модуль координує Git reconcile: інвентаризує branches, worktrees і stash, передає LLM лише semantic triage та conflict/behavioral рішення, а механічне перенесення, перевірки, PR readiness і cleanup виконує детерміновано. Довгі install/test/lint/checks-команди працюють асинхронно, щоб progress heartbeat не блокувався.
15
14
 
16
15
  ## Поведінка
17
16
 
18
- `runGitReconcileOrchestrator` керує всім потоком: збирає Git-стан через `inventoryRepository`, веде фазовий progress через `createPhaseProgress`, запускає bounded triage, матеріалізує рішення у PR-пайплайн, виконує cleanup і повертає звіт через `formatReport`. Паралельність PR-фази обмежується `normalizePrConcurrency`, а незалежні задачі виконуються через `runWithConcurrency` зі стабільним порядком результатів.
17
+ `runGitReconcileOrchestrator` виконує чотири однозначно підписані фази: inventory, triage, PR і cleanup. `createPhaseProgress` формує append-only snapshots та heartbeat, `runWithConcurrency` обмежує паралельність PR-груп, а `formatOutcomeCounts` і `formatReport` повертають точні counts та деталі кожного outcome.
19
18
 
20
- `inventoryRepository` отримує факти з локального checkout і remote refs, оновлює remote refs через fetch --prune, але не змінює робочі файли. `parseWorktrees` і `dedupeRefs` зводять worktree-захист, branch refs, aliases і commit identity в один детермінований inventory, щоб protected або відкриті джерела не потрапили в небезпечний cleanup. `conflictFiles` додає до inventory ранню оцінку потенційних merge-conflicts.
19
+ `inventoryRepository`, `parseWorktrees` і `dedupeRefs` зводять Git refs, commit identity, open PR та фактичні worktree paths в один inventory. Protected або вже відкриті джерела не матеріалізуються й не потрапляють у небезпечний cleanup. `buildTriagePrompt`, `parseDecisionEnvelope` і `validateTriageOutcome` обмежують LLM повним JSON-рішенням над уже зібраними фактами; `callWithValidatedFallback` приймає `min` після validation або викликає `max` лише для residual cognitive failure.
21
20
 
22
- Для review-кандидатів `buildTriagePrompt` формує обмежене завдання для LLM: модель отримує вже пораховані Git-факти й має повернути лише JSON-рішення. `callRunner` викликає обраний runner, `parseDecisionEnvelope` дістає структуровану відповідь, а `validateTriageOutcome` приймає тільки повний і узгоджений набір рішень для поточного batch. `callWithValidatedFallback` спершу пробує дешевший рівень, а дорожчий використовує лише після конкретного validation failure.
21
+ Для корисної групи JS створює ізольований worktree від policy base, застосовує commits або stash і відсікає порожній tree diff. `captureCachedBehaviorBaseline` кешує Promise baseline за base OID, щоб concurrent PR-групи не дублювали test run. `validateBehaviorState` перевіряє Git state, scoped docs/lint, tests відносно baseline і changelog. `remediateBehaviorState` запускає canonical fixers для code та non-code directories; після remediation final gates повторюються у read-only режимі.
23
22
 
24
- Під час матеріалізації рішення source переноситься у керований worktree. `branchSlug` забезпечує передбачувані rescue-гілки без крайового дефіса після скорочення, а `ensureLocalWorktreeExclude` не дає службовим worktree забруднювати root `git status`. Після `@7n/mt` actual checkout визначається через `git worktree list --porcelain`, тому collision sanitized-каталогу не веде до реконструйованого cwd. Setup failure повертається як `failed` лише для цієї PR-групи з branch, фактичним worktree та `spawnSync`-діагностикою; source і forensic checkout не очищуються. Якщо cherry-pick стає порожнім, `skipEmptyCherryPick` дозволяє skip лише для доведеного semantic no-op, а `finishCherryPick` завершує активний перенос без прийняття неперевіреного Git-стану. `hasChangesFromBase` відсікає PR без реального tree diff.
23
+ `runAsync` запускає install, tests, lint і PR checks без блокування event loop. Після push та `gh pr create` функція `verifyPullRequestReadiness` очікує terminal checks і передає набори PR/base checks у `classifyPullRequestChecks`. Успішний outcome `pr-created` повертається лише для `ready`; regression, baseline-red, pending, timeout або unreadable checks зберігають branch, URL і worktree та блокують cleanup.
25
24
 
26
- Behavior-gates будуються навколо baseline: `captureBehaviorBaseline` фіксує стан чистої `origin/<baseBranch>` із repository Git policy, а `captureCachedBehaviorBaseline` перевикористовує цей результат у межах одного прогону для однакової бази. `validateBehaviorState` перевіряє Git-консистентність, scoped lint/docs і test outcome; правила запуску тестів і скриптів беруться з `package.json`. `testFailureSignatures` нормалізує failures, а `acceptsTestOutcome` дозволяє red baseline тільки без нових failures. Якщо валідація падає через типовий formatting, CSpell, docs або changelog-дефект, `remediateBehaviorState` запускає canonical fixers перед ескалацією LLM.
27
-
28
- Для scoped gates `sourceDirectories` звужує code-зміни до мінімальних директорій, а `changedNonCodeDirectories` окремо готує non-code області для фінальної перевірки. `validateFinalProjectGates` добирає domain lint для workflows, dependency manifests, rules та інших non-code змін після того, як code-директорії вже пройшли свої перевірки.
29
-
30
- Після успішного перенесення або доведеної неактуальності `cleanupSource` видаляє тільки точний source, який не є protected і не має відкритого PR. Усі accepted, kept, dropped, failed і cleaned результати зводяться в детермінований Markdown через `formatReport`, щоб наступний крок бачив і створені PR, і причини fail-closed рішень.
25
+ `cleanupSource` видаляє лише точний доказово безпечний branch/stash. Cleanup review-source дозволений після `drop` або коли всі його групи завершились `pr-created` чи `patch-equivalent`; `failed` і всі `pr-checks-*` outcomes лишають source та forensic worktree.
31
26
 
32
27
  ## Публічний API
33
28
 
34
- - createPhaseProgressСтворює ANSI-free snapshot progress для однієї фази.
35
- - parseWorktreesПарсить `git worktree list --porcelain` у branch→path.
36
- - dedupeRefs — Дедуплікує local/remote refs одного commit.
37
- - conflictFilesВитягає конфліктні файли з `git merge-tree`.
38
- - inventoryRepositoryЗбирає детермінований Git inventory.
39
- - buildTriagePromptФормує bounded semantic-triage prompt.
40
- - parseDecisionEnvelope Витягає JSON object із чистої або fenced відповіді.
41
- - callRunnerВикликає вибраний LLM runner для одного bounded-завдання.
42
- - callWithValidatedFallbackВиконує min, валідує результат і викликає max лише після validation failure.
43
- - validateTriageOutcomeСтруктурно перевіряє triage-рішення.
44
- - branchSlug — Перетворює title/ref на валідний короткий branch slug.
45
- - ensureLocalWorktreeExclude — Додає `.worktrees/` до локального Git exclude без tracked-змін.
46
- - skipEmptyCherryPick — Пропускає лише підтверджений empty cherry-pick.
47
- - finishCherryPick — Завершує активний cherry-pick.
48
- - hasChangesFromBase — Перевіряє реальний tree diff, а не commits ahead.
49
- - testFailureSignatures — Витягає стабільні Vitest failure identifiers.
50
- - acceptsTestOutcome — Дозволяє red baseline лише без нових failures.
51
- - sourceDirectories — Зводить code paths до найвужчих директорій для scoped gates.
52
- - changedNonCodeDirectories — Повертає директорії non-code змін для domain lint.
53
- - remediateBehaviorState — Запускає canonical fixers перед behavioral max fallback.
54
- - captureBehaviorBaseline — Фіксує test baseline на чистій policy base branch.
55
- - captureCachedBehaviorBaseline — Кешує test baseline між PR-групами.
56
- - validateBehaviorState — Додає Git-state validation, tests і changelog gate.
57
- - validateFinalProjectGates — Перевіряє фінальні non-code domain gates.
58
- - cleanupSource — Видаляє тільки доказово безпечний точний source.
59
- - formatReport — Формує deterministic report.
60
- - runWithConcurrency — Виконує async jobs із bounded concurrency та стабільним output.
61
- - normalizePrConcurrency — Нормалізує bounded concurrency PR-фази.
62
- - runGitReconcileOrchestrator — Координує inventory, triage, PR і cleanup.
29
+ - `runAsync`виконує довгу команду без блокування progress heartbeat.
30
+ - `createPhaseProgress`формує ANSI-free progress snapshots і heartbeat.
31
+ - `parseWorktrees`, `dedupeRefs`, `conflictFiles`, `inventoryRepository` збирають і нормалізують Git inventory.
32
+ - `buildTriagePrompt`, `parseDecisionEnvelope`, `validateTriageOutcome` задають і перевіряють bounded triage contract.
33
+ - `callRunner`, `callWithValidatedFallback` викликають runner за схемою `min → validation → max`.
34
+ - `captureBehaviorBaseline`, `captureCachedBehaviorBaseline` фіксують і кешують test baseline.
35
+ - `validateBehaviorState`, `validateFinalProjectGates`, `remediateBehaviorState` виконують behavioral та canonical gates.
36
+ - `classifyPullRequestChecks`, `verifyPullRequestReadiness` класифікують CI відносно base commit.
37
+ - `formatOutcomeCounts`, `formatReport` формують точний deterministic summary.
38
+ - `cleanupSource`, `runGitReconcileOrchestrator` виконують безпечний cleanup і координують повний flow.
63
39
 
64
40
  ## Гарантії поведінки
65
41
 
66
- - Progress є append-only та не містить ANSI cursor-control sequences.
67
- - Не більше чотирьох PR-груп виконуються одночасно; типовий ліміт три.
68
- - Canonical fixers мають пріоритет перед behavioral `max` fallback.
69
- - Cleanup починається лише після завершення всіх PR jobs і не видаляє protected/open-PR/failed sources.
70
- - `spawnSync.error`, зокрема `ENOENT`, не губиться в command diagnostics.
71
- - Setup failure зберігає forensic worktree і не зупиняє незалежні PR-групи.
72
- - Test baseline кешується за OID policy base branch у межах одного прогону.
42
+ - Довгі child processes не блокують event loop і progress heartbeat.
43
+ - Baseline test Promise дедуплікується між concurrent PR-групами одного base OID.
44
+ - Canonical fixers охоплюють code/non-code scope, після чого gates повторюються без fix.
45
+ - PR вважається створеним успішно лише після terminal green checks.
46
+ - CI regression, baseline-red або непідтверджений стан завершують orchestration non-zero і зберігають forensic refs/worktree.
47
+ - Cleanup не видаляє protected, open-PR, kept, failed або `pr-checks-*` sources.
48
+ - `spawnSync.error`, зокрема `ENOENT`, зберігається в command diagnostics.
@@ -1,5 +1,6 @@
1
1
  /** @see ./docs/orchestrate.md */
2
- import { spawnSync } from 'node:child_process'
2
+ import { spawn, spawnSync } from 'node:child_process'
3
+ import { once } from 'node:events'
3
4
  import { appendFileSync, existsSync, readFileSync } from 'node:fs'
4
5
  import { dirname, isAbsolute, join } from 'node:path'
5
6
  import { performance } from 'node:perf_hooks'
@@ -12,6 +13,7 @@ const LLM_TIERS = ['min', 'max']
12
13
  const REVIEW_BATCH_SIZE = 10
13
14
  const PROMPT_TEXT_LIMIT = 12_000
14
15
  const PROGRESS_HEARTBEAT_MS = 30_000
16
+ const PR_CHECK_TIMEOUT_MS = 15 * 60_000
15
17
  const DEFAULT_PR_CONCURRENCY = 3
16
18
  const MAX_PR_CONCURRENCY = 4
17
19
  const SOURCE_BRANCH_PREFIX = 'branch:'
@@ -28,11 +30,24 @@ const REF_INVENTORY_FORMAT = ['%(refname)', '%00', '%(object', 'name)', '%00', '
28
30
  ''
29
31
  )
30
32
 
33
+ /** @typedef {(command:string,args:string[],options:object)=>object|Promise<object>} CommandRunner */
34
+
31
35
  /** Порожній callback для опційного progress log. */
32
36
  function noop() {
33
37
  // Навмисно порожньо: caller не запросив progress output.
34
38
  }
35
39
 
40
+ /**
41
+ * Форматує spawn error без вкладених template literals.
42
+ * @param {object|undefined|null} error process error
43
+ * @returns {string} діагностика
44
+ */
45
+ function formatProcessError(error) {
46
+ if (!error) return ''
47
+ const code = error.code ? ` ${error.code}` : ''
48
+ return `${error.name ?? 'Error'}${code}: ${error.message}`
49
+ }
50
+
36
51
  /**
37
52
  * @param {string} cwd корінь репозиторію
38
53
  * @returns {string} remote ref базової гілки
@@ -154,7 +169,7 @@ function run(command, args, cwd, spawnFn, options = {}) {
154
169
  status: result.status ?? 1,
155
170
  stdout: result.stdout ?? '',
156
171
  stderr: result.stderr ?? '',
157
- error: result.error ? `${result.error.name ?? 'Error'}${result.error.code ? ` ${result.error.code}` : ''}: ${result.error.message}` : ''
172
+ error: formatProcessError(result.error)
158
173
  }
159
174
  if (!options.allowFailure && normalized.status !== 0) {
160
175
  throw new Error(
@@ -164,6 +179,100 @@ function run(command, args, cwd, spawnFn, options = {}) {
164
179
  return normalized
165
180
  }
166
181
 
182
+ /**
183
+ * Виконує довгу команду без блокування event loop, щоб progress heartbeat
184
+ * продовжував працювати під час install/test/lint/PR checks. Інжектований
185
+ * sync runner у unit tests також підтримується.
186
+ * @param {string} command виконуваний файл
187
+ * @param {string[]} args аргументи
188
+ * @param {string} cwd робочий каталог
189
+ * @param {CommandRunner} spawnFn async або sync runner
190
+ * @param {{ allowFailure?: boolean, input?: string, timeoutMs?: number }} [options] режим
191
+ * @returns {Promise<{status:number,stdout:string,stderr:string,error:string}>} результат
192
+ */
193
+ export async function runAsync(command, args, cwd, spawnFn, options = {}) {
194
+ const childEnv = { ...env, GIT_EDITOR: 'true' }
195
+ if (command === 'npx') delete childEnv.npm_config_package
196
+ const spawned = spawnFn(command, args, {
197
+ cwd,
198
+ env: childEnv,
199
+ stdio: ['pipe', 'pipe', 'pipe']
200
+ })
201
+ if (!spawned || typeof spawned.on !== 'function') {
202
+ const result = await spawned
203
+ const normalized = {
204
+ status: result?.status ?? 1,
205
+ stdout: result?.stdout ?? '',
206
+ stderr: result?.stderr ?? '',
207
+ error: formatProcessError(result?.error)
208
+ }
209
+ if (!options.allowFailure && normalized.status !== 0) {
210
+ throw new Error(
211
+ `${command} ${args.join(' ')} → exit ${normalized.status}: ${normalized.stderr || normalized.stdout || normalized.error}`
212
+ )
213
+ }
214
+ return normalized
215
+ }
216
+
217
+ const stdout = []
218
+ const stderr = []
219
+ let processError = ''
220
+ let timedOut = false
221
+ spawned.stdout?.setEncoding?.('utf8')
222
+ spawned.stderr?.setEncoding?.('utf8')
223
+ spawned.stdout?.on('data', chunk => {
224
+ stdout.push(chunk)
225
+ })
226
+ spawned.stderr?.on('data', chunk => {
227
+ stderr.push(chunk)
228
+ })
229
+ spawned.on('error', error => {
230
+ processError = formatProcessError(error)
231
+ })
232
+ if (options.input === undefined) spawned.stdin?.end()
233
+ else spawned.stdin?.end(options.input)
234
+
235
+ const timer =
236
+ options.timeoutMs > 0
237
+ ? setTimeout(() => {
238
+ timedOut = true
239
+ spawned.kill('SIGTERM')
240
+ }, options.timeoutMs)
241
+ : null
242
+ timer?.unref?.()
243
+ let status
244
+ try {
245
+ const [exitCode] = await once(spawned, 'close')
246
+ status = exitCode ?? 1
247
+ } catch {
248
+ status = 1
249
+ }
250
+ if (timer) clearTimeout(timer)
251
+ const normalized = {
252
+ status,
253
+ stdout: stdout.join(''),
254
+ stderr: stderr.join(''),
255
+ error: timedOut ? `Timeout after ${options.timeoutMs}ms` : processError
256
+ }
257
+ if (!options.allowFailure && normalized.status !== 0) {
258
+ throw new Error(
259
+ `${command} ${args.join(' ')} → exit ${normalized.status}: ${normalized.stderr || normalized.stdout || normalized.error}`
260
+ )
261
+ }
262
+ return normalized
263
+ }
264
+
265
+ /**
266
+ * Production використовує non-blocking spawn, а існуючі sync test doubles
267
+ * лишаються єдиним джерелом детермінованих результатів.
268
+ * @param {CommandRunner} spawnFn sync runner
269
+ * @param {CommandRunner|null|undefined} asyncSpawnFn явний async runner
270
+ * @returns {CommandRunner} runner довгих команд
271
+ */
272
+ function resolveAsyncSpawn(spawnFn, asyncSpawnFn) {
273
+ return asyncSpawnFn ?? (spawnFn === spawnSync ? spawn : spawnFn)
274
+ }
275
+
167
276
  /**
168
277
  * Запускає git у конкретному checkout.
169
278
  * @param {string[]} args аргументи git
@@ -946,12 +1055,14 @@ export function changedNonCodeDirectories(cwd, spawnFn = spawnSync) {
946
1055
  * @param {string} cwd worktree
947
1056
  * @param {typeof spawnSync} spawnFn інжект
948
1057
  * @param {(stage:string)=>void} [onProgress] stage callback
949
- * @returns {{ok:boolean,error?:string}} gate
1058
+ * @param {CommandRunner|null} [asyncSpawnFn] async runner
1059
+ * @returns {Promise<{ok:boolean,error?:string}>} gate
950
1060
  */
951
- function validateScopedProjectGates(cwd, spawnFn, onProgress = noop) {
1061
+ async function validateScopedProjectGates(cwd, spawnFn, onProgress = noop, asyncSpawnFn = null) {
1062
+ const longRunner = resolveAsyncSpawn(spawnFn, asyncSpawnFn)
952
1063
  for (const path of changedSourceDirectories(cwd, spawnFn)) {
953
1064
  onProgress(`doc-files (${path})`)
954
- const docs = run('npx', ['@7n/rules', 'lint', 'doc-files', '--path', path], cwd, spawnFn, {
1065
+ const docs = await runAsync('npx', ['@7n/rules', 'lint', 'doc-files', '--path', path], cwd, longRunner, {
955
1066
  allowFailure: true
956
1067
  })
957
1068
  if (docs.status !== 0) {
@@ -962,7 +1073,7 @@ function validateScopedProjectGates(cwd, spawnFn, onProgress = noop) {
962
1073
  }
963
1074
  }
964
1075
  onProgress(`scoped lint (${path})`)
965
- const lint = run('npx', ['@7n/rules', 'lint', '--path', path, '--no-fix'], cwd, spawnFn, {
1076
+ const lint = await runAsync('npx', ['@7n/rules', 'lint', '--path', path, '--no-fix'], cwd, longRunner, {
966
1077
  allowFailure: true
967
1078
  })
968
1079
  if (lint.status !== 0) {
@@ -983,14 +1094,24 @@ function validateScopedProjectGates(cwd, spawnFn, onProgress = noop) {
983
1094
  * @param {typeof spawnSync} spawnFn інжект
984
1095
  * @param {{remediation?:string}} validation провалена validation
985
1096
  * @param {(stage:string)=>void} [onProgress] stage callback
986
- * @returns {{attempted:boolean,ok:boolean,error?:string}} результат
1097
+ * @param {CommandRunner|null} [asyncSpawnFn] async runner
1098
+ * @returns {Promise<{attempted:boolean,ok:boolean,error?:string}>} результат
987
1099
  */
988
- export function remediateBehaviorState(cwd, spawnFn = spawnSync, validation = {}, onProgress = noop) {
1100
+ export async function remediateBehaviorState(
1101
+ cwd,
1102
+ spawnFn = spawnSync,
1103
+ validation = {},
1104
+ onProgress = noop,
1105
+ asyncSpawnFn = null
1106
+ ) {
989
1107
  if (validation.remediation !== 'canonical-fixers') return { attempted: false, ok: false }
990
1108
 
991
- for (const path of changedSourceDirectories(cwd, spawnFn)) {
1109
+ const longRunner = resolveAsyncSpawn(spawnFn, asyncSpawnFn)
1110
+ const changedDirectories = [...new Set([...changedSourceDirectories(cwd, spawnFn), ...changedNonCodeDirectories(cwd, spawnFn)])]
1111
+ .toSorted()
1112
+ for (const path of changedDirectories) {
992
1113
  onProgress(`deterministic fix (${path})`)
993
- const fixed = run('npx', ['@7n/rules', 'lint', '--path', path], cwd, spawnFn, {
1114
+ const fixed = await runAsync('npx', ['@7n/rules', 'lint', '--path', path], cwd, longRunner, {
994
1115
  allowFailure: true
995
1116
  })
996
1117
  if (fixed.status !== 0) {
@@ -1003,7 +1124,7 @@ export function remediateBehaviorState(cwd, spawnFn = spawnSync, validation = {}
1003
1124
  }
1004
1125
 
1005
1126
  onProgress('deterministic changelog fix')
1006
- const changelog = run('npx', ['@7n/rules', 'lint', 'changelog'], cwd, spawnFn, {
1127
+ const changelog = await runAsync('npx', ['@7n/rules', 'lint', 'changelog'], cwd, longRunner, {
1007
1128
  allowFailure: true
1008
1129
  })
1009
1130
  if (changelog.status !== 0) {
@@ -1020,25 +1141,30 @@ export function remediateBehaviorState(cwd, spawnFn = spawnSync, validation = {}
1020
1141
  * Встановлює frozen Bun dependencies, якщо новий worktree їх ще не має.
1021
1142
  * @param {string} cwd worktree
1022
1143
  * @param {typeof spawnSync} spawnFn інжект
1144
+ * @param {CommandRunner|undefined} asyncSpawnFn async runner
1145
+ * @returns {Promise<void>} завершення install
1023
1146
  */
1024
- function ensureWorktreeDependencies(cwd, spawnFn) {
1147
+ async function ensureWorktreeDependencies(cwd, spawnFn, asyncSpawnFn) {
1025
1148
  if (!existsSync(join(cwd, 'package.json')) || !existsSync(join(cwd, 'bun.lock'))) return
1026
1149
  if (existsSync(join(cwd, 'node_modules'))) return
1027
- run('bun', ['install', '--frozen-lockfile'], cwd, spawnFn)
1150
+ await runAsync('bun', ['install', '--frozen-lockfile'], cwd, resolveAsyncSpawn(spawnFn, asyncSpawnFn))
1028
1151
  }
1029
1152
 
1030
1153
  /**
1031
1154
  * Фіксує test baseline на чистій policy base гілці до перенесення source.
1032
1155
  * @param {string} cwd worktree
1033
1156
  * @param {typeof spawnSync} spawnFn інжект
1034
- * @returns {{tests:{status:number,stdout:string,stderr:string}|null}} baseline
1157
+ * @param {CommandRunner|null} [asyncSpawnFn] async runner
1158
+ * @returns {Promise<{tests:{status:number,stdout:string,stderr:string}|null}>} baseline
1035
1159
  */
1036
- export function captureBehaviorBaseline(cwd, spawnFn = spawnSync) {
1037
- ensureWorktreeDependencies(cwd, spawnFn)
1160
+ export async function captureBehaviorBaseline(cwd, spawnFn = spawnSync, asyncSpawnFn = null) {
1161
+ await ensureWorktreeDependencies(cwd, spawnFn, asyncSpawnFn)
1038
1162
  const packageJsonPath = join(cwd, 'package.json')
1039
1163
  if (!existsSync(packageJsonPath)) return { tests: null }
1040
1164
  const packageJson = parseJson(readFileSync(packageJsonPath, 'utf8'), {})
1041
- const tests = packageJson?.scripts?.test ? run('bun', ['run', 'test'], cwd, spawnFn, { allowFailure: true }) : null
1165
+ const tests = packageJson?.scripts?.test
1166
+ ? await runAsync('bun', ['run', 'test'], cwd, resolveAsyncSpawn(spawnFn, asyncSpawnFn), { allowFailure: true })
1167
+ : null
1042
1168
  return { tests }
1043
1169
  }
1044
1170
 
@@ -1048,15 +1174,23 @@ export function captureBehaviorBaseline(cwd, spawnFn = spawnSync) {
1048
1174
  * @param {string} cwd worktree
1049
1175
  * @param {Map<string,object>} cache кеш за OID бази
1050
1176
  * @param {typeof spawnSync} spawnFn інжект
1051
- * @returns {{baseline:object,cached:boolean}} baseline і ознака cache hit
1177
+ * @param {CommandRunner|null} [asyncSpawnFn] async runner
1178
+ * @returns {Promise<{baseline:object,cached:boolean}>} baseline і ознака cache hit
1052
1179
  */
1053
- export function captureCachedBehaviorBaseline(cwd, cache, spawnFn = spawnSync) {
1054
- ensureWorktreeDependencies(cwd, spawnFn)
1180
+ export async function captureCachedBehaviorBaseline(cwd, cache, spawnFn = spawnSync, asyncSpawnFn = null) {
1181
+ await ensureWorktreeDependencies(cwd, spawnFn, asyncSpawnFn)
1055
1182
  const baseOid = git(['rev-parse', policyBaseRef(cwd)], cwd, spawnFn).stdout.trim()
1056
- if (cache.has(baseOid)) return { baseline: cache.get(baseOid), cached: true }
1057
- const baseline = captureBehaviorBaseline(cwd, spawnFn)
1058
- cache.set(baseOid, baseline)
1059
- return { baseline, cached: false }
1183
+ if (cache.has(baseOid)) return { baseline: await cache.get(baseOid), cached: true }
1184
+ const pending = captureBehaviorBaseline(cwd, spawnFn, asyncSpawnFn)
1185
+ cache.set(baseOid, pending)
1186
+ try {
1187
+ const baseline = await pending
1188
+ cache.set(baseOid, baseline)
1189
+ return { baseline, cached: false }
1190
+ } catch (error) {
1191
+ cache.delete(baseOid)
1192
+ throw error
1193
+ }
1060
1194
  }
1061
1195
 
1062
1196
  /**
@@ -1066,12 +1200,19 @@ export function captureCachedBehaviorBaseline(cwd, cache, spawnFn = spawnSync) {
1066
1200
  * @param {typeof spawnSync} spawnFn інжект
1067
1201
  * @param {{tests:{status:number,stdout:string,stderr:string}|null}|null} [baseline] стан policy base гілки
1068
1202
  * @param {(stage:string)=>void} [onProgress] stage callback
1069
- * @returns {{ok:boolean,error?:string}} validation
1203
+ * @param {CommandRunner|null} [asyncSpawnFn] async runner
1204
+ * @returns {Promise<{ok:boolean,error?:string}>} validation
1070
1205
  */
1071
- export function validateBehaviorState(cwd, spawnFn = spawnSync, baseline = null, onProgress = noop) {
1206
+ export async function validateBehaviorState(
1207
+ cwd,
1208
+ spawnFn = spawnSync,
1209
+ baseline = null,
1210
+ onProgress = noop,
1211
+ asyncSpawnFn = null
1212
+ ) {
1072
1213
  const gitState = validateGitState(cwd, spawnFn)
1073
1214
  if (!gitState.ok) return gitState
1074
- const projectGates = validateScopedProjectGates(cwd, spawnFn, onProgress)
1215
+ const projectGates = await validateScopedProjectGates(cwd, spawnFn, onProgress, asyncSpawnFn)
1075
1216
  if (!projectGates.ok) return projectGates
1076
1217
  const postGateGitState = validateGitState(cwd, spawnFn)
1077
1218
  if (!postGateGitState.ok) return postGateGitState
@@ -1081,7 +1222,9 @@ export function validateBehaviorState(cwd, spawnFn = spawnSync, baseline = null,
1081
1222
  const packageJson = parseJson(readFileSync(packageJsonPath, 'utf8'), {})
1082
1223
  if (packageJson?.scripts?.test) {
1083
1224
  onProgress('project tests')
1084
- const tests = run('bun', ['run', 'test'], cwd, spawnFn, { allowFailure: true })
1225
+ const tests = await runAsync('bun', ['run', 'test'], cwd, resolveAsyncSpawn(spawnFn, asyncSpawnFn), {
1226
+ allowFailure: true
1227
+ })
1085
1228
  if (!acceptsTestOutcome(baseline?.tests ?? null, tests)) {
1086
1229
  return { ok: false, error: `bun run test: ${tests.stderr || tests.stdout}` }
1087
1230
  }
@@ -1089,7 +1232,13 @@ export function validateBehaviorState(cwd, spawnFn = spawnSync, baseline = null,
1089
1232
  }
1090
1233
 
1091
1234
  onProgress('changelog')
1092
- const changelog = run('npx', ['@7n/rules', 'lint', 'changelog', '--no-fix'], cwd, spawnFn, { allowFailure: true })
1235
+ const changelog = await runAsync(
1236
+ 'npx',
1237
+ ['@7n/rules', 'lint', 'changelog', '--no-fix'],
1238
+ cwd,
1239
+ resolveAsyncSpawn(spawnFn, asyncSpawnFn),
1240
+ { allowFailure: true }
1241
+ )
1093
1242
  if (changelog.status !== 0) {
1094
1243
  return {
1095
1244
  ok: false,
@@ -1105,19 +1254,33 @@ export function validateBehaviorState(cwd, spawnFn = spawnSync, baseline = null,
1105
1254
  * manifests і правила. Code directories уже пройшли scoped lint і tests.
1106
1255
  * @param {string} cwd worktree
1107
1256
  * @param {typeof spawnSync} spawnFn інжект
1108
- * @returns {{ok:boolean,error?:string}} gate
1257
+ * @param {CommandRunner|null} [asyncSpawnFn] async runner
1258
+ * @returns {Promise<{ok:boolean,error?:string}>} gate
1109
1259
  */
1110
- export function validateFinalProjectGates(cwd, spawnFn = spawnSync) {
1260
+ export async function validateFinalProjectGates(cwd, spawnFn = spawnSync, asyncSpawnFn = null) {
1261
+ const longRunner = resolveAsyncSpawn(spawnFn, asyncSpawnFn)
1111
1262
  for (const path of changedNonCodeDirectories(cwd, spawnFn)) {
1112
- const lint = run('npx', ['@7n/rules', 'lint', '--path', path, '--no-fix'], cwd, spawnFn, {
1263
+ const lint = await runAsync('npx', ['@7n/rules', 'lint', '--path', path, '--no-fix'], cwd, longRunner, {
1113
1264
  allowFailure: true
1114
1265
  })
1115
- if (lint.status !== 0) return { ok: false, error: `domain lint (${path}): ${lint.stderr || lint.stdout}` }
1266
+ if (lint.status !== 0) {
1267
+ return {
1268
+ ok: false,
1269
+ error: `domain lint (${path}): ${lint.stderr || lint.stdout}`,
1270
+ remediation: 'canonical-fixers'
1271
+ }
1272
+ }
1116
1273
  }
1117
- const changelog = run('npx', ['@7n/rules', 'lint', 'changelog', '--no-fix'], cwd, spawnFn, {
1274
+ const changelog = await runAsync('npx', ['@7n/rules', 'lint', 'changelog', '--no-fix'], cwd, longRunner, {
1118
1275
  allowFailure: true
1119
1276
  })
1120
- if (changelog.status !== 0) return { ok: false, error: `changelog gate: ${changelog.stderr || changelog.stdout}` }
1277
+ if (changelog.status !== 0) {
1278
+ return {
1279
+ ok: false,
1280
+ error: `changelog gate: ${changelog.stderr || changelog.stdout}`,
1281
+ remediation: 'canonical-fixers'
1282
+ }
1283
+ }
1121
1284
  return { ok: true }
1122
1285
  }
1123
1286
 
@@ -1212,17 +1375,160 @@ async function finalizeBehavior(args) {
1212
1375
  label: `behavior ${source}`,
1213
1376
  onAttempt: ({ tier }) => onProgress('behavior validation', tier),
1214
1377
  validate: () =>
1215
- validateBehaviorState(worktreeCwd, spawnFn, baseline, step => onProgress(`behavior validation: ${step}`)),
1378
+ validateBehaviorState(
1379
+ worktreeCwd,
1380
+ spawnFn,
1381
+ baseline,
1382
+ step => onProgress(`behavior validation: ${step}`),
1383
+ deps.asyncSpawnFn
1384
+ ),
1216
1385
  remediate: validation =>
1217
- remediateBehaviorState(worktreeCwd, spawnFn, validation, step => onProgress(`behavior validation: ${step}`))
1386
+ remediateBehaviorState(
1387
+ worktreeCwd,
1388
+ spawnFn,
1389
+ validation,
1390
+ step => onProgress(`behavior validation: ${step}`),
1391
+ deps.asyncSpawnFn
1392
+ )
1218
1393
  })
1219
1394
  if (!outcome.ok) throw new Error(`LLM behavioral verification: ${outcome.error}`)
1220
1395
  return outcome.text.slice(0, PROMPT_TEXT_LIMIT)
1221
1396
  }
1222
1397
 
1398
+ /**
1399
+ * Нормалізує GitHub check/status до стабільного імені та стану.
1400
+ * @param {object} check statusCheckRollup або check-run
1401
+ * @returns {{name:string,state:'success'|'failure'|'pending'}} нормалізований check
1402
+ */
1403
+ function normalizeGitHubCheck(check) {
1404
+ const name = check.name ?? check.context ?? check.workflowName ?? 'unnamed-check'
1405
+ const rawState = String(check.conclusion ?? check.state ?? check.status ?? '').toUpperCase()
1406
+ const successful = ['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(rawState)
1407
+ const pending = ['', 'EXPECTED', 'PENDING', 'QUEUED', 'IN_PROGRESS', 'REQUESTED', 'WAITING'].includes(rawState)
1408
+ let state = 'failure'
1409
+ if (successful) state = 'success'
1410
+ else if (pending) state = 'pending'
1411
+ return { name, state }
1412
+ }
1413
+
1414
+ /**
1415
+ * Класифікує PR checks відносно checks base commit. Будь-який pending/unknown
1416
+ * стан fail-closed зберігає worktree; baseline-red дозволений лише коли кожен
1417
+ * failed check уже падає на base.
1418
+ * @param {object[]} prChecks PR statusCheckRollup
1419
+ * @param {object[]} baseChecks check-runs base commit
1420
+ * @returns {{status:'ready'|'pr-checks-regressed'|'pr-checks-baseline-red'|'pr-checks-unverified',error?:string}} класифікація
1421
+ */
1422
+ export function classifyPullRequestChecks(prChecks, baseChecks) {
1423
+ const normalizedPr = prChecks.map(check => normalizeGitHubCheck(check))
1424
+ const pending = normalizedPr.filter(check => check.state === 'pending')
1425
+ if (pending.length > 0) {
1426
+ return {
1427
+ status: 'pr-checks-unverified',
1428
+ error: `Незавершені PR checks: ${pending.map(check => check.name).join(', ')}`
1429
+ }
1430
+ }
1431
+ const failed = normalizedPr.filter(check => check.state === 'failure')
1432
+ if (failed.length === 0) return { status: 'ready' }
1433
+
1434
+ const failedOnBase = new Set(
1435
+ baseChecks
1436
+ .map(check => normalizeGitHubCheck(check))
1437
+ .filter(check => check.state === 'failure')
1438
+ .map(check => check.name)
1439
+ )
1440
+ const regressions = failed.filter(check => !failedOnBase.has(check.name))
1441
+ if (regressions.length > 0) {
1442
+ return {
1443
+ status: 'pr-checks-regressed',
1444
+ error: `Нові failed PR checks: ${regressions.map(check => check.name).join(', ')}`
1445
+ }
1446
+ }
1447
+ return {
1448
+ status: 'pr-checks-baseline-red',
1449
+ error: `PR checks повторюють failed base checks: ${failed.map(check => check.name).join(', ')}`
1450
+ }
1451
+ }
1452
+
1453
+ /**
1454
+ * Чекає terminal CI state й порівнює failed checks з base commit.
1455
+ * @param {object} args PR context
1456
+ * @returns {Promise<{status:string,error?:string}>} readiness
1457
+ */
1458
+ export async function verifyPullRequestReadiness(args) {
1459
+ const { url, cwd, spawnFn = spawnSync, asyncSpawnFn } = args
1460
+ const longRunner = resolveAsyncSpawn(spawnFn, asyncSpawnFn)
1461
+ await runAsync('gh', ['pr', 'checks', url, '--watch', '--interval', '10'], cwd, longRunner, {
1462
+ allowFailure: true,
1463
+ timeoutMs: PR_CHECK_TIMEOUT_MS
1464
+ })
1465
+ const view = await runAsync(
1466
+ 'gh',
1467
+ ['pr', 'view', url, '--json', 'statusCheckRollup,baseRefOid'],
1468
+ cwd,
1469
+ longRunner,
1470
+ { allowFailure: true }
1471
+ )
1472
+ if (view.status !== 0) {
1473
+ return { status: 'pr-checks-unverified', error: `Не вдалося прочитати PR checks: ${view.stderr || view.error}` }
1474
+ }
1475
+ const pr = parseJson(view.stdout, null)
1476
+ if (!pr || !Array.isArray(pr.statusCheckRollup) || !pr.baseRefOid) {
1477
+ return { status: 'pr-checks-unverified', error: 'GitHub повернув неповний PR check rollup' }
1478
+ }
1479
+ const repository = await runAsync(
1480
+ 'gh',
1481
+ ['repo', 'view', '--json', 'nameWithOwner', '--jq', '.nameWithOwner'],
1482
+ cwd,
1483
+ longRunner,
1484
+ { allowFailure: true }
1485
+ )
1486
+ if (repository.status !== 0 || !repository.stdout.trim()) {
1487
+ return { status: 'pr-checks-unverified', error: `Не вдалося визначити GitHub repository: ${repository.stderr}` }
1488
+ }
1489
+ const base = await runAsync(
1490
+ 'gh',
1491
+ ['api', `repos/${repository.stdout.trim()}/commits/${pr.baseRefOid}/check-runs?per_page=100`],
1492
+ cwd,
1493
+ longRunner,
1494
+ { allowFailure: true }
1495
+ )
1496
+ if (base.status !== 0) {
1497
+ return { status: 'pr-checks-unverified', error: `Не вдалося прочитати base checks: ${base.stderr || base.error}` }
1498
+ }
1499
+ const basePayload = parseJson(base.stdout, null)
1500
+ if (!basePayload || !Array.isArray(basePayload.check_runs)) {
1501
+ return { status: 'pr-checks-unverified', error: 'GitHub повернув неповний base check rollup' }
1502
+ }
1503
+ return classifyPullRequestChecks(pr.statusCheckRollup, basePayload.check_runs)
1504
+ }
1505
+
1506
+ /**
1507
+ * Запускає final gates і один canonical remediation pass.
1508
+ * @param {object} args gate context
1509
+ * @returns {Promise<{ok:boolean,error?:string}>} фінальний стан
1510
+ */
1511
+ async function passFinalProjectGates(args) {
1512
+ const { cwd, spawnFn, asyncSpawnFn, onProgress } = args
1513
+ let finalGates = await validateFinalProjectGates(cwd, spawnFn, asyncSpawnFn)
1514
+ if (finalGates.ok || finalGates.remediation !== 'canonical-fixers') return finalGates
1515
+
1516
+ onProgress('canonical final remediation')
1517
+ const remediation = await remediateBehaviorState(
1518
+ cwd,
1519
+ spawnFn,
1520
+ finalGates,
1521
+ step => onProgress(`canonical final remediation: ${step}`),
1522
+ asyncSpawnFn
1523
+ )
1524
+ if (!remediation.ok) return { ok: false, error: remediation.error ?? finalGates.error }
1525
+ finalGates = await validateFinalProjectGates(cwd, spawnFn, asyncSpawnFn)
1526
+ return finalGates
1527
+ }
1528
+
1223
1529
  /**
1224
1530
  * Створює один готовий PR. При будь-якому провалі worktree лишається для
1225
- * ручного відновлення; прибирається тільки після успішного gh pr create.
1531
+ * ручного відновлення; прибирається тільки після успішних CI checks.
1226
1532
  * @param {object} args параметри
1227
1533
  * @returns {Promise<{status:string,url?:string,branch?:string,error?:string,worktree?:string}>} результат
1228
1534
  */
@@ -1247,7 +1553,7 @@ async function createPullRequest(args) {
1247
1553
  let baseline = null
1248
1554
  if (sourceMayChangeCode) {
1249
1555
  onProgress('baseline tests')
1250
- const captured = captureCachedBehaviorBaseline(worktree.cwd, baselineCache, spawnFn)
1556
+ const captured = await captureCachedBehaviorBaseline(worktree.cwd, baselineCache, spawnFn, deps.asyncSpawnFn)
1251
1557
  baseline = captured.baseline
1252
1558
  if (captured.cached) onProgress('baseline tests (cached)')
1253
1559
  }
@@ -1292,15 +1598,22 @@ async function createPullRequest(args) {
1292
1598
  git(['diff', '--cached', '--quiet'], worktree.cwd, spawnFn, {
1293
1599
  allowFailure: true
1294
1600
  }).status !== 0
1295
- if (staged) git(['commit', '-m', group.title], worktree.cwd, spawnFn)
1601
+ if (!staged) throw new Error('Після Git validation немає staged змін')
1296
1602
  if (!hasChangesFromBase(worktree.cwd, spawnFn)) {
1297
1603
  onProgress('remove no-op worktree')
1298
1604
  removeReconcileWorktree(worktree, rootCwd, spawnFn)
1299
1605
  return { status: 'patch-equivalent', branch: worktree.branch }
1300
1606
  }
1301
1607
  onProgress('delta lint')
1302
- const finalGates = validateFinalProjectGates(worktree.cwd, spawnFn)
1608
+ const finalGates = await passFinalProjectGates({
1609
+ cwd: worktree.cwd,
1610
+ spawnFn,
1611
+ asyncSpawnFn: deps.asyncSpawnFn,
1612
+ onProgress
1613
+ })
1303
1614
  if (!finalGates.ok) throw new Error(finalGates.error)
1615
+ git(['add', '-A'], worktree.cwd, spawnFn)
1616
+ git(['commit', '-m', group.title], worktree.cwd, spawnFn)
1304
1617
  const baseRef = policyBaseRef(worktree.cwd)
1305
1618
  const baseBranch = readGitPolicy(worktree.cwd).baseBranch
1306
1619
  git(['diff', '--check', `${baseRef}...HEAD`], worktree.cwd, spawnFn)
@@ -1327,6 +1640,23 @@ async function createPullRequest(args) {
1327
1640
  worktree.cwd,
1328
1641
  spawnFn
1329
1642
  ).stdout.trim()
1643
+ onProgress('PR checks')
1644
+ const readinessVerifier = deps.verifyPullRequestReadiness ?? verifyPullRequestReadiness
1645
+ const readiness = await readinessVerifier({
1646
+ url: createdPr,
1647
+ cwd: worktree.cwd,
1648
+ spawnFn,
1649
+ asyncSpawnFn: deps.asyncSpawnFn
1650
+ })
1651
+ if (readiness.status !== 'ready') {
1652
+ return {
1653
+ status: readiness.status,
1654
+ error: readiness.error,
1655
+ url: createdPr,
1656
+ branch: worktree.branch,
1657
+ worktree: worktree.cwd
1658
+ }
1659
+ }
1330
1660
  onProgress('remove worktree')
1331
1661
  removeReconcileWorktree(worktree, rootCwd, spawnFn)
1332
1662
  return { status: 'pr-created', url: createdPr, branch: worktree.branch }
@@ -1392,13 +1722,29 @@ function formatRemovedRefs(cleanup) {
1392
1722
  return `; refs=${refs}`
1393
1723
  }
1394
1724
 
1725
+ /**
1726
+ * Рахує точні outcomes без змішування створеного PR з CI-ready PR.
1727
+ * @param {Array<{status:string}>} results результати materialization
1728
+ * @returns {string} стабільний summary
1729
+ */
1730
+ export function formatOutcomeCounts(results) {
1731
+ const counts = new Map()
1732
+ for (const result of results) counts.set(result.status, (counts.get(result.status) ?? 0) + 1)
1733
+ return counts
1734
+ .keys()
1735
+ .toArray()
1736
+ .toSorted((left, right) => left.localeCompare(right))
1737
+ .map(status => `${status}=${counts.get(status)}`)
1738
+ .join(', ')
1739
+ }
1740
+
1395
1741
  /**
1396
1742
  * Формує deterministic report.
1397
1743
  * @param {{inventory:object,results:Array<object>}} args дані
1398
1744
  * @returns {string} markdown
1399
1745
  */
1400
1746
  export function formatReport({ inventory, results }) {
1401
- const lines = ['## git-reconcile: підсумок']
1747
+ const lines = ['## git-reconcile: підсумок', `- Outcomes: ${formatOutcomeCounts(results) || 'none'}`]
1402
1748
  for (const branch of inventory.branches) {
1403
1749
  if (branch.state === 'review') continue
1404
1750
  let suffix = ''
@@ -1697,7 +2043,7 @@ export async function runGitReconcileOrchestrator(options = {}) {
1697
2043
  const baselineCache = new Map()
1698
2044
 
1699
2045
  const inventoryStartedAt = now()
1700
- log('⏳ 1/4 inventory')
2046
+ log('⏳ етап 1/4: inventory')
1701
2047
  const inventory = inventoryFn(rootCwd, { spawnFn })
1702
2048
  if (deps.ensureLocalWorktreeExclude !== false) {
1703
2049
  const ensureExclude = deps.ensureLocalWorktreeExclude ?? ensureLocalWorktreeExclude
@@ -1705,14 +2051,14 @@ export async function runGitReconcileOrchestrator(options = {}) {
1705
2051
  }
1706
2052
  const candidates = [...inventory.branches, ...inventory.stashes].filter(item => item.state === 'review')
1707
2053
  log(
1708
- `✅ 1/4 inventory · ${elapsedLabel(inventoryStartedAt, now)} · ${inventory.branches.length} branches · ${inventory.stashes.length} stash`
2054
+ `✅ етап 1/4: inventory · ${elapsedLabel(inventoryStartedAt, now)} · ${inventory.branches.length} branches · ${inventory.stashes.length} stash`
1709
2055
  )
1710
2056
 
1711
2057
  const triageTotal = Math.ceil(candidates.length / REVIEW_BATCH_SIZE)
1712
2058
  const triageProgress = createPhaseProgress({
1713
2059
  total: triageTotal,
1714
2060
  unitLabel: 'triage-пакетів',
1715
- phase: '2/4 triage',
2061
+ phase: 'етап 2/4: triage',
1716
2062
  log,
1717
2063
  now,
1718
2064
  heartbeatMs,
@@ -1734,7 +2080,7 @@ export async function runGitReconcileOrchestrator(options = {}) {
1734
2080
  } finally {
1735
2081
  triageProgress.stop()
1736
2082
  }
1737
- log(`✅ 2/4 triage · ${elapsedLabel(triageStartedAt, now)} · ${triageTotal} batches`)
2083
+ log(`✅ етап 2/4: triage · ${elapsedLabel(triageStartedAt, now)} · ${triageTotal} batches`)
1738
2084
 
1739
2085
  const prTotal = decisions.reduce((total, decision) => {
1740
2086
  return total + (decision.action === 'pr' && Array.isArray(decision.groups) ? decision.groups.length : 0)
@@ -1742,7 +2088,7 @@ export async function runGitReconcileOrchestrator(options = {}) {
1742
2088
  const prProgress = createPhaseProgress({
1743
2089
  total: prTotal,
1744
2090
  unitLabel: 'PR-груп',
1745
- phase: '3/4 PR',
2091
+ phase: 'етап 3/4: PR',
1746
2092
  log,
1747
2093
  now,
1748
2094
  heartbeatMs,
@@ -1769,14 +2115,16 @@ export async function runGitReconcileOrchestrator(options = {}) {
1769
2115
  } finally {
1770
2116
  prProgress.stop()
1771
2117
  }
1772
- log(`✅ 3/4 PR · ${elapsedLabel(prStartedAt, now)} · ${prTotal} groups`)
2118
+ log(
2119
+ `✅ етап 3/4: PR · ${elapsedLabel(prStartedAt, now)} · ${prTotal} groups · ${formatOutcomeCounts(materialized.results) || 'none'}`
2120
+ )
1773
2121
 
1774
2122
  const { bySource, results } = materialized
1775
2123
  const cleanupCount = countCleanupSources(inventory, bySource, results)
1776
2124
  const cleanupProgress = createPhaseProgress({
1777
2125
  total: cleanupCount,
1778
2126
  unitLabel: 'джерел',
1779
- phase: '4/4 cleanup',
2127
+ phase: 'етап 4/4: cleanup',
1780
2128
  log,
1781
2129
  now,
1782
2130
  heartbeatMs,
@@ -1806,10 +2154,16 @@ export async function runGitReconcileOrchestrator(options = {}) {
1806
2154
  } finally {
1807
2155
  cleanupProgress.stop()
1808
2156
  }
1809
- log(`✅ 4/4 cleanup · ${elapsedLabel(cleanupStartedAt, now)} · ${cleanupCount} sources`)
2157
+ log(`✅ етап 4/4: cleanup · ${elapsedLabel(cleanupStartedAt, now)} · ${cleanupCount} sources`)
1810
2158
 
1811
2159
  const report = formatReport({ inventory, results })
1812
2160
  log(report)
1813
- const ok = results.every(result => result.status !== 'failed' && result.incomplete !== true)
2161
+ const blockingStatuses = new Set([
2162
+ 'failed',
2163
+ 'pr-checks-regressed',
2164
+ 'pr-checks-baseline-red',
2165
+ 'pr-checks-unverified'
2166
+ ])
2167
+ const ok = results.every(result => !blockingStatuses.has(result.status) && result.incomplete !== true)
1814
2168
  return { ok, report, inventory, results }
1815
2169
  }