@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 +12 -0
- package/package.json +1 -1
- package/scripts/lib/lint-surface/docs/run-detectors.md +4 -2
- package/scripts/lib/lint-surface/run-detectors.mjs +46 -3
- package/skills/git-reconcile/SKILL.md +13 -4
- package/skills/git-reconcile/js/docs/orchestrate.md +25 -49
- package/skills/git-reconcile/js/orchestrate.mjs +405 -51
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
|
@@ -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:
|
|
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-каталогів,
|
|
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
|
|
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
|
|
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
|
|
108
|
-
|
|
109
|
-
|
|
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:
|
|
7
|
-
model:
|
|
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
|
-
|
|
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`
|
|
17
|
+
`runGitReconcileOrchestrator` виконує чотири однозначно підписані фази: inventory, triage, PR і cleanup. `createPhaseProgress` формує append-only snapshots та heartbeat, `runWithConcurrency` обмежує паралельність PR-груп, а `formatOutcomeCounts` і `formatReport` повертають точні counts та деталі кожного outcome.
|
|
19
18
|
|
|
20
|
-
`inventoryRepository`
|
|
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
|
-
Для
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
35
|
-
-
|
|
36
|
-
- dedupeRefs —
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
-
|
|
41
|
-
-
|
|
42
|
-
-
|
|
43
|
-
-
|
|
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
|
-
-
|
|
67
|
-
-
|
|
68
|
-
- Canonical fixers
|
|
69
|
-
-
|
|
70
|
-
-
|
|
71
|
-
-
|
|
72
|
-
-
|
|
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
|
|
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
|
-
* @
|
|
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 =
|
|
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 =
|
|
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
|
-
* @
|
|
1097
|
+
* @param {CommandRunner|null} [asyncSpawnFn] async runner
|
|
1098
|
+
* @returns {Promise<{attempted:boolean,ok:boolean,error?:string}>} результат
|
|
987
1099
|
*/
|
|
988
|
-
export function remediateBehaviorState(
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
* @
|
|
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
|
|
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
|
-
* @
|
|
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
|
|
1058
|
-
cache.set(baseOid,
|
|
1059
|
-
|
|
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
|
-
* @
|
|
1203
|
+
* @param {CommandRunner|null} [asyncSpawnFn] async runner
|
|
1204
|
+
* @returns {Promise<{ok:boolean,error?:string}>} validation
|
|
1070
1205
|
*/
|
|
1071
|
-
export function validateBehaviorState(
|
|
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 =
|
|
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 =
|
|
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
|
-
* @
|
|
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 =
|
|
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)
|
|
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 =
|
|
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)
|
|
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(
|
|
1378
|
+
validateBehaviorState(
|
|
1379
|
+
worktreeCwd,
|
|
1380
|
+
spawnFn,
|
|
1381
|
+
baseline,
|
|
1382
|
+
step => onProgress(`behavior validation: ${step}`),
|
|
1383
|
+
deps.asyncSpawnFn
|
|
1384
|
+
),
|
|
1216
1385
|
remediate: validation =>
|
|
1217
|
-
remediateBehaviorState(
|
|
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
|
-
* ручного відновлення; прибирається тільки після
|
|
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)
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
}
|