@7n/rules 1.49.11 → 1.49.13
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 +2 -2
- package/rules/changelog/consistency/docs/main.md +2 -2
- package/rules/changelog/consistency/main.mjs +4 -1
- package/rules/test/coverage/fix-worker.mjs +3 -1
- package/scripts/lib/changed-files.mjs +4 -1
- package/scripts/lib/docs/changed-files.md +2 -2
- package/scripts/lib/docs/git-policy.md +1 -1
- package/scripts/lib/git-policy.mjs +4 -2
- package/scripts/lib/lint-surface/run-fix.mjs +5 -1
- package/skills/git-reconcile/SKILL.md +26 -13
- package/skills/git-reconcile/js/docs/orchestrate.md +70 -48
- package/skills/git-reconcile/js/orchestrate.mjs +299 -88
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.49.13] - 2026-07-26
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- pass coverage mutation verdict feedback to the next ladder rung
|
|
8
|
+
|
|
9
|
+
## [1.49.12] - 2026-07-26
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Git-reconcile отримав ANSI-free progress, heartbeat, canonical fixer pass і bounded parallel PR jobs.
|
|
14
|
+
|
|
3
15
|
## [1.49.11] - 2026-07-26
|
|
4
16
|
|
|
5
17
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@7n/rules",
|
|
3
|
-
"version": "1.49.
|
|
3
|
+
"version": "1.49.13",
|
|
4
4
|
"description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cli",
|
|
@@ -65,7 +65,7 @@
|
|
|
65
65
|
"rename-yaml-extensions": "bun ./bin/n-rules.js rename-yaml-extensions"
|
|
66
66
|
},
|
|
67
67
|
"dependencies": {
|
|
68
|
-
"@7n/llm-lib": "2.9.
|
|
68
|
+
"@7n/llm-lib": "2.9.7",
|
|
69
69
|
"@7n/mt": "^0.5.1",
|
|
70
70
|
"@zed-industries/agent-client-protocol": "^0.4.5",
|
|
71
71
|
"ajv": "^8.20.0",
|
|
@@ -3,7 +3,7 @@ type: JS Module
|
|
|
3
3
|
title: main.mjs
|
|
4
4
|
resource: npm/rules/changelog/consistency/main.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: 3d8d2a45
|
|
7
7
|
model: openai-codex/gpt-5.4-mini
|
|
8
8
|
tier: cloud-min
|
|
9
9
|
score: 95
|
|
@@ -22,7 +22,7 @@ docgen:
|
|
|
22
22
|
3. Для кожного workspace оцінює, чи є релевантні зміни відносно цієї бази, і відсікає шум від ігнорованих changelog-шляхів та ignore-правил. Це потрібно, щоб перевірка реагувала лише на зміни, які справді потребують bump або change-файлу.
|
|
23
23
|
4. Для published workspace перевіряє, що changelog присутній і має базовий формат, а також що опублікована версія не відстає від локальної. Якщо версія в дереві випереджає опубліковану — це вважається ручним bump поза CI, а не штатним станом.
|
|
24
24
|
5. Якщо published workspace має релевантні Git-зміни без change-файлу, або фіксує це як помилку, або в autofix-режимі створює change-файл із автоматичним описом і ставить його в індекс, щоб коміт не падав. Для published-пакетів додатково вимагається, щоб `files` у маніфесті містив `CHANGELOG.md`, бо релізний changelog має публікуватися разом із пакетом.
|
|
25
|
-
6. Для workspace, які змінюються лише локально, звіряє версію з базою порівняння, перевіряє наявність
|
|
25
|
+
6. Для workspace, які змінюються лише локально, звіряє версію з базою порівняння, перевіряє наявність невипущених change-файлів і, якщо треба, також пропонує або створює change-файл. Повідомлення формуються в стилі, сумісному з правилами ``.
|
|
26
26
|
7. Резолвить опубліковану версію залежно від типу пакета: для Python орієнтується на PyPI, для інших — на npm. Пошук публікації йде через `https://pypi.org/pypi/`, а також через конфігурацію `res.json`, якщо вона визначає очікувану поведінку workspace.
|
|
27
27
|
8. Працює fail-safe: мережеві чи git-помилки не пробиваються назовні як винятки, а перетворюються на м’який результат або перевірочний фейл, щоб один збій не ламав увесь lint-прогін.
|
|
28
28
|
|
|
@@ -602,7 +602,10 @@ async function checkPublishedWorkspacePendingGitChanges(manifest, _Vcurrent, sub
|
|
|
602
602
|
return
|
|
603
603
|
}
|
|
604
604
|
|
|
605
|
-
if (
|
|
605
|
+
if (
|
|
606
|
+
policy.releaseBranches.includes(branch ?? '') &&
|
|
607
|
+
(await workspaceHasRelevantChangesAgainstBase('HEAD', manifest.ws, subWorkspaces, cwd))
|
|
608
|
+
) {
|
|
606
609
|
await fixOrFailPublishedWorkspace(manifest, label, mf, autofix, pass, fail, cwd)
|
|
607
610
|
}
|
|
608
611
|
}
|
|
@@ -90,6 +90,7 @@ export async function fixWorker(violations, ctx, deps = {}) {
|
|
|
90
90
|
const touchedFiles = []
|
|
91
91
|
/** @type {Array<{provider: string, hook: string, files: string[], error: string}>} */
|
|
92
92
|
const failed = []
|
|
93
|
+
let feedback = null
|
|
93
94
|
const deferred = []
|
|
94
95
|
/**
|
|
95
96
|
* Викликає опційний fix-hook провайдера, збирає touchedFiles; виняток хука не
|
|
@@ -104,6 +105,7 @@ export async function fixWorker(violations, ctx, deps = {}) {
|
|
|
104
105
|
try {
|
|
105
106
|
const res = await provider[hook]({ ...args, cwd: ctx.cwd, ctx: hookCtx(ctx, deadlineAt) })
|
|
106
107
|
touchedFiles.push(...(res?.touchedFiles ?? []))
|
|
108
|
+
if (res?.feedback?.previousError) feedback = res.feedback
|
|
107
109
|
deferred.push(...(res?.deferred ?? []))
|
|
108
110
|
for (const failure of res?.failed ?? []) {
|
|
109
111
|
const error = failure?.error ?? 'невідома причина'
|
|
@@ -135,5 +137,5 @@ export async function fixWorker(violations, ctx, deps = {}) {
|
|
|
135
137
|
}
|
|
136
138
|
}
|
|
137
139
|
|
|
138
|
-
return { touchedFiles, failed, deferred }
|
|
140
|
+
return { touchedFiles, failed, deferred, feedback }
|
|
139
141
|
}
|
|
@@ -67,7 +67,10 @@ export function resolveChangedBase(cwd = process.cwd(), baseRef = null) {
|
|
|
67
67
|
}
|
|
68
68
|
if (baseRef) return mergeBaseWith(baseRef) || null
|
|
69
69
|
const { integrationBranches } = readGitPolicy(cwd)
|
|
70
|
-
const bases = integrationBranches
|
|
70
|
+
const bases = integrationBranches
|
|
71
|
+
.flatMap(name => [`origin/${name}`, name])
|
|
72
|
+
.map(ref => mergeBaseWith(ref))
|
|
73
|
+
.filter(Boolean)
|
|
71
74
|
if (bases.length === 0) return null
|
|
72
75
|
let newest = bases[0]
|
|
73
76
|
for (const candidate of bases.slice(1)) {
|
|
@@ -3,7 +3,7 @@ type: JS Module
|
|
|
3
3
|
title: changed-files.mjs
|
|
4
4
|
resource: npm/scripts/lib/changed-files.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
6
|
+
crc: 2bf61f5b
|
|
7
7
|
model: openai-codex/gpt-5.5
|
|
8
8
|
tier: cloud-avg
|
|
9
9
|
score: 100
|
|
@@ -25,7 +25,7 @@ docgen:
|
|
|
25
25
|
|
|
26
26
|
`collectChangedFilesSince` споживає базу з `resolveChangedBase` або її відсутність. За наявної бази вона збирає всі файли, змінені від цієї бази до поточного робочого дерева, включно із закоміченими після бази, staged і незакоміченими змінами, та додає untracked файли. За відсутньої бази делегує збір до `collectChangedFiles`.
|
|
27
27
|
|
|
28
|
-
Усі результати є списками унікальних шляхів для наступних lint-етапів. Для обох режимів діє спільне правило: видалені файли не потрапляють у scope, а файли всередині службових worktree-чекаутів відкидаються, щоб сесійні копії репозиторію не
|
|
28
|
+
Усі результати є списками унікальних шляхів для наступних lint-етапів. Для обох режимів діє спільне правило: видалені файли не потрапляють у scope, а файли всередині службових worktree-чекаутів відкидаються, щоб сесійні копії репозиторію не перевірялися як робочий код. Поза git-репозиторієм або при помилці git звичайний quick-збір повертає порожній список, але scoped-збір із недосяжною базою завершується явною помилкою, щоб перевірка не пройшла з порожнім scope помилково.
|
|
29
29
|
|
|
30
30
|
## Публічний API
|
|
31
31
|
|
|
@@ -22,7 +22,7 @@ function branchName(value) {
|
|
|
22
22
|
* Повертає effective policy. `releaseBranches` одночасно є integration-кандидатами
|
|
23
23
|
* delta та захищеними довгоживучими гілками; `baseBranch` додається до обох наборів.
|
|
24
24
|
* @param {string} [cwd] корінь репозиторію
|
|
25
|
-
* @returns {{baseBranch:string,releaseBranches:string[],integrationBranches:string[],protectedBranches:string[]}}
|
|
25
|
+
* @returns {{baseBranch:string,releaseBranches:string[],integrationBranches:string[],protectedBranches:string[]}} effective Git policy
|
|
26
26
|
*/
|
|
27
27
|
export function readGitPolicy(cwd = process.cwd()) {
|
|
28
28
|
let raw = null
|
|
@@ -38,7 +38,9 @@ export function readGitPolicy(cwd = process.cwd()) {
|
|
|
38
38
|
}
|
|
39
39
|
const git = raw && typeof raw.git === 'object' && !Array.isArray(raw.git) ? raw.git : {}
|
|
40
40
|
const baseBranch = branchName(git.baseBranch) ?? DEFAULT_BASE_BRANCH
|
|
41
|
-
const configuredRelease = Array.isArray(git.releaseBranches)
|
|
41
|
+
const configuredRelease = Array.isArray(git.releaseBranches)
|
|
42
|
+
? git.releaseBranches.map(value => branchName(value)).filter(Boolean)
|
|
43
|
+
: []
|
|
42
44
|
const releaseBranches = configuredRelease.length > 0 ? [...new Set(configuredRelease)] : [DEFAULT_BASE_BRANCH]
|
|
43
45
|
const integrationBranches = [...new Set([baseBranch, ...releaseBranches])]
|
|
44
46
|
return { baseBranch, releaseBranches, integrationBranches, protectedBranches: integrationBranches }
|
|
@@ -454,6 +454,10 @@ async function runRung(rung, worker, violations, feedback, rungDeps) {
|
|
|
454
454
|
|
|
455
455
|
const vetoed = after.length === 0 && !error && (collateralAll.length > 0 || brokenTest !== null)
|
|
456
456
|
const touchedFiles = workerResult?.touchedFiles ?? []
|
|
457
|
+
// Provider може повернути quality-verdict без worker exception (наприклад,
|
|
458
|
+
// generated coverage test зелений у Vitest, але не вбив target mutant). Це
|
|
459
|
+
// не змінює retry policy; лише дає наступному ladder rung-у конкретний feedback.
|
|
460
|
+
const workerFeedback = workerResult?.feedback?.previousError ?? null
|
|
457
461
|
|
|
458
462
|
if (after.length === 0 && !error && !vetoed) {
|
|
459
463
|
log(` ✅ ${rung.tier} (${rung.model}): ${ruleId}/${concernName}\n`)
|
|
@@ -501,7 +505,7 @@ async function runRung(rung, worker, violations, feedback, rungDeps) {
|
|
|
501
505
|
outcome: {
|
|
502
506
|
action: decideAfterFailure(rung, error),
|
|
503
507
|
violations: after.length > 0 ? after : violations,
|
|
504
|
-
feedback: { previousModel: rung.model, previousError: error ?? silentFailureNote }
|
|
508
|
+
feedback: { previousModel: rung.model, previousError: error ?? workerFeedback ?? silentFailureNote }
|
|
505
509
|
}
|
|
506
510
|
}
|
|
507
511
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: n-git-reconcile
|
|
3
3
|
description: >-
|
|
4
|
-
JS-оркестрований аналіз git-гілок, worktree та stash відносно
|
|
5
|
-
|
|
4
|
+
JS-оркестрований аналіз git-гілок, worktree та stash відносно актуальної
|
|
5
|
+
policy base branch: детерміновано відсіює merged і patch-equivalent refs, передає
|
|
6
6
|
LLM лише semantic triage та conflict resolution, а корисні зміни переносить
|
|
7
7
|
у перевірені PR. Використовуй, коли просять розібрати, консолідувати,
|
|
8
8
|
підготувати PR або безпечно почистити старі Git refs і stash.
|
|
@@ -22,7 +22,7 @@ npx @7n/rules skill pi git-reconcile
|
|
|
22
22
|
## Розподіл відповідальності
|
|
23
23
|
|
|
24
24
|
JS виконує `fetch`, inventory, patch-equivalence, дедуплікацію refs, збір
|
|
25
|
-
worktree/PR/stash, підготовку worktree від `origin
|
|
25
|
+
worktree/PR/stash, підготовку worktree від `origin/<baseBranch>`, cherry-pick або
|
|
26
26
|
застосування stash, gates, commit, push, PR і фінальний звіт. Semantic no-op
|
|
27
27
|
після conflict resolution детерміновано пропускає через `cherry-pick --skip`;
|
|
28
28
|
порожній tree diff не push-иться і не створює PR.
|
|
@@ -39,30 +39,38 @@ doc generation, lint і changelog gates запускає JS після cognitive
|
|
|
39
39
|
Doc-files і unified lint отримують лише унікальні директорії зміненого коду,
|
|
40
40
|
щоб repository-wide baseline та stale docs поза scope не забруднювали PR.
|
|
41
41
|
|
|
42
|
-
Оркестратор показує чесний фазовий progress: inventory має elapsed
|
|
43
|
-
вигаданого total, а `triage`, `PR` і `cleanup` — окремі точні
|
|
44
|
-
відомими batches/groups/sources. Поточний
|
|
45
|
-
`max
|
|
42
|
+
Оркестратор показує чесний ANSI-free фазовий progress: inventory має elapsed
|
|
43
|
+
time без вигаданого total, а `triage`, `PR` і `cleanup` — окремі точні
|
|
44
|
+
append-only bar snapshots за вже відомими batches/groups/sources. Поточний
|
|
45
|
+
LLM-етап показує tier `min` або `max`; довгі етапи кожні 30 секунд отримують
|
|
46
|
+
heartbeat з elapsed time. Формат однаковий у TTY/CI, тому captured output не
|
|
47
|
+
містить cursor-control spam.
|
|
48
|
+
|
|
49
|
+
Незалежні PR-групи виконуються з bounded concurrency `3`; override
|
|
50
|
+
`N_GIT_RECONCILE_CONCURRENCY=1..4`. Порядок фінального звіту лишається
|
|
51
|
+
детермінованим, а cleanup починається лише після завершення всіх PR jobs.
|
|
46
52
|
|
|
47
53
|
Кожен LLM-крок починається на tier `min`. JS детерміновано перевіряє:
|
|
48
54
|
|
|
49
55
|
- triage — повноту verdicts, schema, groups і commit OID;
|
|
50
56
|
- worktree — відсутність conflict markers та `git diff --check`;
|
|
51
|
-
- поведінку — repository test script відносно test baseline
|
|
52
|
-
`origin
|
|
57
|
+
- поведінку — repository test script відносно test baseline чистої
|
|
58
|
+
`origin/<baseBranch>` і changelog gate. Red baseline приймається лише для
|
|
53
59
|
розпізнаних Vitest failures, якщо після перенесення не додалось нових.
|
|
54
60
|
- змінений код — scoped `doc-files` у fix-режимі та unified lint у
|
|
55
61
|
`--no-fix`, окремо для кожної code directory.
|
|
56
62
|
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
Після min validation failure JS спершу запускає canonical scoped/changelog
|
|
64
|
+
fixers. Якщо вони детерміновано усунули format, CSpell, docs або changeset
|
|
65
|
+
дефект, min приймається без `max`. Лише residual behavioral failure запускає
|
|
66
|
+
повтор того самого bounded-завдання на `max` із точною причиною. Infrastructure
|
|
59
67
|
failure runner завершує крок одразу: повтор іншою моделлю не маскує проблему
|
|
60
68
|
transport. Після провалу `max` джерело fail-closed лишається `kept` або
|
|
61
69
|
`failed`, не потрапляє в cleanup, а неповний triage завершує команду non-zero.
|
|
62
70
|
|
|
63
71
|
## Інваріанти
|
|
64
72
|
|
|
65
|
-
- База — тільки свіжий `origin
|
|
73
|
+
- База — тільки свіжий `origin/<baseBranch>` із repository Git policy.
|
|
66
74
|
- Живі worktree, включно з detached HEAD за commit OID, та гілки відкритих
|
|
67
75
|
PR — protected.
|
|
68
76
|
- Стара дата або великий divergence не означають, що зміна непотрібна.
|
|
@@ -72,13 +80,18 @@ transport. Після провалу `max` джерело fail-closed лишає
|
|
|
72
80
|
відсутніх conflicts і порожнього staged diff.
|
|
73
81
|
- Вкладені `npx` не успадковують package selector зовнішнього
|
|
74
82
|
`npm exec --package`.
|
|
83
|
+
- Перед створенням worktree JS додає `.worktrees/` до локального
|
|
84
|
+
`.git/info/exclude`, не змінюючи tracked `.gitignore` consumer-а.
|
|
85
|
+
- ACP semantic idle watchdog не подовжується від `usage`, thought,
|
|
86
|
+
config або повторних tool-update events; його скидають лише новий tool-call
|
|
87
|
+
чи agent output.
|
|
75
88
|
- За невизначеності джерело лишається `kept`; misleading ready PR не
|
|
76
89
|
створюється.
|
|
77
90
|
- Перед push обов'язково проходять фінальний tree-diff guard, domain lint для
|
|
78
91
|
non-code paths, changelog і `git diff --check`; code changes додатково
|
|
79
92
|
проходять scoped docs/lint та tests.
|
|
80
93
|
- Behavioral LLM не викликається для змін без code paths; test baseline
|
|
81
|
-
|
|
94
|
+
актуальної policy base branch кешується між PR-групами.
|
|
82
95
|
- Cleanup виконує лише JS і тільки після inventory/PR-фази: видаляє точні refs,
|
|
83
96
|
уже merged/patch-equivalent, явно класифіковані як `drop` або повністю
|
|
84
97
|
перенесені в успішний PR.
|
|
@@ -3,64 +3,86 @@ 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
|
-
|
|
6
|
+
crc: c3233b69
|
|
7
|
+
model: openai-codex/gpt-5.5
|
|
8
|
+
tier: cloud-avg
|
|
9
|
+
score: 100
|
|
9
10
|
---
|
|
10
11
|
|
|
11
12
|
## Огляд
|
|
12
13
|
|
|
13
|
-
|
|
14
|
-
JS виконує inventory, validation, materialization, gates, PR і cleanup, а LLM
|
|
15
|
-
отримує лише bounded semantic triage, conflict resolution та behavioral edits.
|
|
14
|
+
Файл координує Git reconcile-орchestrator: інвентаризує repository refs і worktrees, прибирає дублікати refs, визначає conflict files, формує triage prompt, перевіряє decision envelope та застосовує рішення через runner. Він існує, щоб переносити cleanup у перевірюваний процес із behavior baseline, cached baseline у межах прогону, validation gates, контрольованим concurrency і детермінованим Markdown-звітом.
|
|
16
15
|
|
|
17
16
|
## Поведінка
|
|
18
17
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
|
|
29
|
-
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
domain lint для non-code paths, changelog та `git diff --check`.
|
|
33
|
-
- Після conflict resolution порожній cherry-pick пропускається лише за
|
|
34
|
-
активного `CHERRY_PICK_HEAD`, відсутніх conflicts і порожнього staged diff.
|
|
35
|
-
- Tree-diff guard двічі відсікає semantic no-op. Порожня група отримує
|
|
36
|
-
`patch-equivalent`, worktree прибирається, push і PR не виконуються.
|
|
37
|
-
- Cleanup видаляє всі точні local/remote aliases і звітує їх. Failed source
|
|
38
|
-
зберігає branch, worktree і, якщо PR уже створено, його URL.
|
|
18
|
+
`runGitReconcileOrchestrator` керує всім потоком: збирає Git-стан через `inventoryRepository`, веде фазовий progress через `createPhaseProgress`, запускає bounded triage, матеріалізує рішення у PR-пайплайн, виконує cleanup і повертає звіт через `formatReport`. Паралельність PR-фази обмежується `normalizePrConcurrency`, а незалежні задачі виконуються через `runWithConcurrency` зі стабільним порядком результатів.
|
|
19
|
+
|
|
20
|
+
`inventoryRepository` отримує факти з локального checkout і remote refs, оновлює remote refs через fetch --prune, але не змінює робочі файли. `parseWorktrees` і `dedupeRefs` зводять worktree-захист, branch refs, aliases і commit identity в один детермінований inventory, щоб protected або відкриті джерела не потрапили в небезпечний cleanup. `conflictFiles` додає до inventory ранню оцінку потенційних merge-conflicts.
|
|
21
|
+
|
|
22
|
+
Для review-кандидатів `buildTriagePrompt` формує обмежене завдання для LLM: модель отримує вже пораховані Git-факти й має повернути лише JSON-рішення. `callRunner` викликає обраний runner, `parseDecisionEnvelope` дістає структуровану відповідь, а `validateTriageOutcome` приймає тільки повний і узгоджений набір рішень для поточного batch. `callWithValidatedFallback` спершу пробує дешевший рівень, а дорожчий використовує лише після конкретного validation failure.
|
|
23
|
+
|
|
24
|
+
Під час матеріалізації рішення source переноситься у керований worktree. `branchSlug` забезпечує передбачувані rescue-гілки, а `ensureLocalWorktreeExclude` не дає службовим worktree забруднювати root `git status`. Якщо cherry-pick стає порожнім, `skipEmptyCherryPick` дозволяє skip лише для доведеного semantic no-op, а `finishCherryPick` завершує активний перенос без прийняття неперевіреного Git-стану. `hasChangesFromBase` відсікає PR без реального tree diff.
|
|
25
|
+
|
|
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 рішень.
|
|
39
31
|
|
|
40
32
|
## Публічний API
|
|
41
33
|
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
-
|
|
46
|
-
-
|
|
47
|
-
-
|
|
48
|
-
-
|
|
49
|
-
-
|
|
50
|
-
|
|
51
|
-
-
|
|
52
|
-
|
|
53
|
-
-
|
|
54
|
-
-
|
|
34
|
+
- createPhaseProgress — Створює ANSI-free snapshot progress для однієї фази. Однаковий append-only
|
|
35
|
+
формат у TTY/CI не засмічує captured output cursor-control кодами, а
|
|
36
|
+
heartbeat показує elapsed time довгих LLM-етапів.
|
|
37
|
+
- parseWorktrees — Парсить `git worktree list --porcelain` у branch→path.
|
|
38
|
+
- dedupeRefs — Дедуплікує local/remote refs одного commit: remote має пріоритет, але
|
|
39
|
+
worktree-protection локального ref переноситься у запис.
|
|
40
|
+
- conflictFiles — Витягає конфліктні файли з `git merge-tree`.
|
|
41
|
+
- inventoryRepository — Збирає детермінований Git inventory. Нічого не видаляє і не змінює у
|
|
42
|
+
checkout, крім оновлення remote refs через fetch --prune.
|
|
43
|
+
- buildTriagePrompt — Формує bounded semantic-triage prompt. Git-факти вже пораховані JS; модель
|
|
44
|
+
не виконує shell-команди й повертає лише JSON-рішення.
|
|
45
|
+
- parseDecisionEnvelope — Витягає JSON object із чистої або fenced відповіді.
|
|
46
|
+
- callRunner — Викликає вибраний LLM runner для одного bounded-завдання.
|
|
47
|
+
- callWithValidatedFallback — Виконує bounded LLM-крок через min, валідовує результат JS-функцією і
|
|
48
|
+
викликає max лише після конкретного провалу.
|
|
49
|
+
- validateTriageOutcome — Структурно перевіряє triage-рішення: рівно один verdict на candidate,
|
|
50
|
+
валідні actions/groups і лише відомі commit OID.
|
|
51
|
+
- branchSlug — Перетворює довільний title/ref на branch slug.
|
|
52
|
+
- ensureLocalWorktreeExclude — Додає `.worktrees/` до локального Git exclude без tracked-змін у consumer.
|
|
53
|
+
Це не замінює repository Vitest excludes, але не лишає root checkout dirty
|
|
54
|
+
через керовані або forensic worktree.
|
|
55
|
+
- skipEmptyCherryPick — Пропускає лише підтверджений empty cherry-pick: sequencer активний,
|
|
56
|
+
конфліктів немає, staged diff порожній.
|
|
57
|
+
- finishCherryPick — Завершує активний cherry-pick: semantic no-op пропускає, непорожній
|
|
58
|
+
продовжує. Відсутній sequencer не потребує дії.
|
|
59
|
+
- hasChangesFromBase — Перевіряє реальний tree diff, а не лише кількість commits ahead.
|
|
60
|
+
- testFailureSignatures — Витягає стабільні Vitest failure identifiers без summary/timing.
|
|
61
|
+
- acceptsTestOutcome — Дозволяє red baseline лише якщо після перенесення не з'явилось нових
|
|
62
|
+
Vitest failures. Нерозпізнаний red output завжди fail-closed.
|
|
63
|
+
- sourceDirectories — Зводить змінені code paths до найвужчих директорій для scoped gates.
|
|
64
|
+
- changedNonCodeDirectories — Повертає директорії non-code змін для фінального domain lint.
|
|
65
|
+
- remediateBehaviorState — Запускає canonical fixers у worktree до ескалації min→max. Це прибирає
|
|
66
|
+
formatting/CSpell/doc/changelog дефекти без повторного behavioral LLM.
|
|
67
|
+
- captureBehaviorBaseline — Фіксує test baseline на чистій policy base branch до перенесення source.
|
|
68
|
+
- captureCachedBehaviorBaseline — Повторно використовує test baseline однієї policy base branch між PR-групами.
|
|
69
|
+
Залежності все одно встановлюються в кожному окремому worktree.
|
|
70
|
+
- validateBehaviorState — Додає до Git-state validation test script із репозиторію і changelog gate.
|
|
71
|
+
Саме ці докази вирішують, чи приймати min-результат або ескалювати на max.
|
|
72
|
+
- validateFinalProjectGates — Фінальний domain gate охоплює non-code зміни, зокрема workflows, dependency
|
|
73
|
+
manifests і правила. Code directories уже пройшли scoped lint і tests.
|
|
74
|
+
- cleanupSource — Видаляє точний source після Git-доказу неактуальності або успішного
|
|
75
|
+
перенесення. Protected/open-PR refs не потрапляють у цей крок.
|
|
76
|
+
- formatReport — Формує deterministic report.
|
|
77
|
+
- runWithConcurrency — Виконує async jobs із bounded concurrency та стабільним порядком output.
|
|
78
|
+
- normalizePrConcurrency — Нормалізує bounded concurrency PR-фази.
|
|
79
|
+
- runGitReconcileOrchestrator — JS-оркестратор: inventory → bounded LLM triage → deterministic PR pipeline.
|
|
55
80
|
|
|
56
81
|
## Гарантії поведінки
|
|
57
82
|
|
|
58
|
-
-
|
|
59
|
-
|
|
60
|
-
-
|
|
61
|
-
|
|
62
|
-
-
|
|
63
|
-
|
|
64
|
-
- Worktree із проваленим перенесенням зберігається для діагностики.
|
|
65
|
-
- Порожній tree diff не може створити remote branch або PR.
|
|
66
|
-
- Progress не змішує фази з різною вартістю в удаваний global percentage.
|
|
83
|
+
- Progress є append-only та не містить ANSI cursor-control sequences.
|
|
84
|
+
- Не більше чотирьох PR-груп виконуються одночасно; типовий ліміт — три.
|
|
85
|
+
- Canonical fixers мають пріоритет перед behavioral `max` fallback.
|
|
86
|
+
- Cleanup починається лише після завершення всіх PR jobs і не видаляє
|
|
87
|
+
protected/open-PR/failed sources.
|
|
88
|
+
- Test baseline кешується за OID policy base branch у межах одного прогону.
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
/** @see ./docs/orchestrate.md */
|
|
2
2
|
import { spawnSync } from 'node:child_process'
|
|
3
|
-
import { existsSync, readFileSync } from 'node:fs'
|
|
4
|
-
import { dirname, join } from 'node:path'
|
|
3
|
+
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, isAbsolute, join } from 'node:path'
|
|
5
5
|
import { performance } from 'node:perf_hooks'
|
|
6
6
|
import { env } from 'node:process'
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { renderProgressLine } from '../../../scripts/lib/lint-surface/progress.mjs'
|
|
9
9
|
import { readGitPolicy } from '../../../scripts/lib/git-policy.mjs'
|
|
10
10
|
|
|
11
11
|
const LLM_TIERS = ['min', 'max']
|
|
12
12
|
const REVIEW_BATCH_SIZE = 10
|
|
13
13
|
const PROMPT_TEXT_LIMIT = 12_000
|
|
14
|
+
const PROGRESS_HEARTBEAT_MS = 30_000
|
|
15
|
+
const DEFAULT_PR_CONCURRENCY = 3
|
|
16
|
+
const MAX_PR_CONCURRENCY = 4
|
|
14
17
|
const SOURCE_BRANCH_PREFIX = 'branch:'
|
|
15
18
|
const SOURCE_STASH_PREFIX = 'stash:'
|
|
16
19
|
const CONTENT_CONFLICT_RE = /^CONFLICT \(.+?\): Merge conflict in (.+)$/
|
|
@@ -30,7 +33,10 @@ function noop() {
|
|
|
30
33
|
// Навмисно порожньо: caller не запросив progress output.
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* @param {string} cwd корінь репозиторію
|
|
38
|
+
* @returns {string} remote ref базової гілки
|
|
39
|
+
*/
|
|
34
40
|
function policyBaseRef(cwd) {
|
|
35
41
|
return `origin/${readGitPolicy(cwd).baseBranch}`
|
|
36
42
|
}
|
|
@@ -47,37 +53,81 @@ function elapsedLabel(startedAt, now) {
|
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
/**
|
|
50
|
-
* Створює
|
|
51
|
-
* у
|
|
56
|
+
* Створює ANSI-free snapshot progress для однієї фази. Однаковий append-only
|
|
57
|
+
* формат у TTY/CI не засмічує captured output cursor-control кодами, а
|
|
58
|
+
* heartbeat показує elapsed time довгих LLM-етапів.
|
|
52
59
|
* @param {object} args параметри фази
|
|
53
60
|
* @returns {{step:(key:string,detail:string,tier?:string)=>void,done:(key:string)=>void,stop:()=>void}} reporter
|
|
54
61
|
*/
|
|
55
|
-
function createPhaseProgress(args) {
|
|
56
|
-
const {
|
|
57
|
-
if (total === 0) return { step: noop, done: noop, stop: noop }
|
|
58
|
-
|
|
59
|
-
const baseLog = text => log(text.endsWith('\n') ? text.slice(0, -1) : text)
|
|
60
|
-
const reporter = reporterFactory({
|
|
62
|
+
export function createPhaseProgress(args) {
|
|
63
|
+
const {
|
|
61
64
|
total,
|
|
62
|
-
log: baseLog,
|
|
63
|
-
isTTY,
|
|
64
65
|
unitLabel,
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
phase,
|
|
67
|
+
log,
|
|
68
|
+
now = () => performance.now(),
|
|
69
|
+
heartbeatMs = PROGRESS_HEARTBEAT_MS,
|
|
70
|
+
setIntervalFn = setInterval,
|
|
71
|
+
clearIntervalFn = clearInterval
|
|
72
|
+
} = args
|
|
73
|
+
if (total === 0) return { step: noop, done: noop, stop: noop }
|
|
74
|
+
|
|
75
|
+
const active = new Map()
|
|
76
|
+
const completed = new Set()
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Рендерить один append-only snapshot без керувальних ANSI-послідовностей.
|
|
80
|
+
* @param {string} prefix статус
|
|
81
|
+
* @param {string} current поточний етап
|
|
82
|
+
* @param {number|null} startedAt початок активної одиниці
|
|
83
|
+
*/
|
|
84
|
+
function render(prefix, current, startedAt = null) {
|
|
85
|
+
const elapsed = startedAt === null ? '' : ` · elapsed ${elapsedLabel(startedAt, now)}`
|
|
86
|
+
log(
|
|
87
|
+
`${prefix} ${renderProgressLine({
|
|
88
|
+
done: completed.size,
|
|
89
|
+
total,
|
|
90
|
+
found: 0,
|
|
91
|
+
fixed: 0,
|
|
92
|
+
current,
|
|
93
|
+
unitLabel,
|
|
94
|
+
withFixed: false
|
|
95
|
+
})}${elapsed}`
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const heartbeat = setIntervalFn(() => {
|
|
100
|
+
if (active.size === 0) return
|
|
101
|
+
const labels = active
|
|
102
|
+
.values()
|
|
103
|
+
.map(item => item.label)
|
|
104
|
+
.toArray()
|
|
105
|
+
.join(' | ')
|
|
106
|
+
const oldest = Math.min(...active.values().map(item => item.startedAt))
|
|
107
|
+
render('💓', labels, oldest)
|
|
108
|
+
}, heartbeatMs)
|
|
109
|
+
heartbeat?.unref?.()
|
|
68
110
|
|
|
69
111
|
return {
|
|
70
|
-
step: (
|
|
112
|
+
step: (key, detail, tier) => {
|
|
71
113
|
const label = `${phase} · ${detail}`
|
|
72
|
-
reporter.concernStart(label, tier)
|
|
73
114
|
const rendered = tier ? `${label} · ${tier}` : label
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
115
|
+
const previous = active.get(key)
|
|
116
|
+
const startedAt = previous?.startedAt ?? now()
|
|
117
|
+
if (previous?.label === rendered) return
|
|
118
|
+
active.set(key, { label: rendered, startedAt })
|
|
119
|
+
render('⏳', rendered, startedAt)
|
|
120
|
+
},
|
|
121
|
+
done: key => {
|
|
122
|
+
if (completed.has(key)) return
|
|
123
|
+
const current = active.get(key)?.label ?? `${phase} · ${key}`
|
|
124
|
+
active.delete(key)
|
|
125
|
+
completed.add(key)
|
|
126
|
+
render('✅', current)
|
|
78
127
|
},
|
|
79
|
-
|
|
80
|
-
|
|
128
|
+
stop: () => {
|
|
129
|
+
clearIntervalFn(heartbeat)
|
|
130
|
+
}
|
|
81
131
|
}
|
|
82
132
|
}
|
|
83
133
|
|
|
@@ -181,6 +231,7 @@ function branchName(ref) {
|
|
|
181
231
|
* @param {Array<{ref:string, oid:string, date:string}>} refs сирі refs
|
|
182
232
|
* @param {Map<string,string>} worktrees branch→path
|
|
183
233
|
* @param {Map<string,string>} worktreeCommits checkout HEAD OID→path
|
|
234
|
+
* @param {string[]} protectedBranches захищені policy branches
|
|
184
235
|
* @returns {Array<{ref:string, oid:string, date:string, worktree:string|null,aliases:string[]}>} refs
|
|
185
236
|
*/
|
|
186
237
|
export function dedupeRefs(refs, worktrees, worktreeCommits = new Map(), protectedBranches = ['main']) {
|
|
@@ -471,7 +522,7 @@ export async function callRunner(runner, prompt, cwd, deps = {}, tier = 'max') {
|
|
|
471
522
|
* @returns {Promise<{ok:boolean,text:string,error:string|null,tier:'min'|'max',validation?:object,attempts:Array<object>}>} результат
|
|
472
523
|
*/
|
|
473
524
|
export async function callWithValidatedFallback(args) {
|
|
474
|
-
const { runner, prompt, cwd, validate, deps = {}, log = noop, label = 'LLM', onAttempt = noop } = args
|
|
525
|
+
const { runner, prompt, cwd, validate, remediate, deps = {}, log = noop, label = 'LLM', onAttempt = noop } = args
|
|
475
526
|
const call = deps.callRunner ?? callRunner
|
|
476
527
|
const attempts = []
|
|
477
528
|
let retryPrompt = prompt
|
|
@@ -484,10 +535,18 @@ export async function callWithValidatedFallback(args) {
|
|
|
484
535
|
attempts.push({ tier, ok: false, validation })
|
|
485
536
|
return { ...outcome, tier, validation, attempts }
|
|
486
537
|
}
|
|
487
|
-
|
|
488
|
-
|
|
538
|
+
let validation = await validate(outcome)
|
|
539
|
+
let remediation = null
|
|
540
|
+
if (!validation.ok && tier === 'min' && remediate) {
|
|
541
|
+
remediation = await remediate(validation)
|
|
542
|
+
if (remediation?.attempted) {
|
|
543
|
+
validation = await validate(outcome)
|
|
544
|
+
if (validation.ok) log(`↺ ${label}: deterministic fixer усунув min validation failure`)
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
attempts.push({ tier, ok: outcome.ok, validation, ...(remediation && { remediation }) })
|
|
489
548
|
if (validation.ok) {
|
|
490
|
-
return { ...outcome, tier, validation, attempts }
|
|
549
|
+
return { ...outcome, tier, validation, attempts, ...(remediation?.attempted && { remediated: true }) }
|
|
491
550
|
}
|
|
492
551
|
if (tier === 'min') {
|
|
493
552
|
const reason = (validation.error ?? outcome.error ?? 'невідома помилка validation').slice(0, PROMPT_TEXT_LIMIT)
|
|
@@ -655,6 +714,29 @@ function createReconcileWorktree(title, source, cwd, spawnFn) {
|
|
|
655
714
|
return { branch, cwd: worktreeCwd }
|
|
656
715
|
}
|
|
657
716
|
|
|
717
|
+
/**
|
|
718
|
+
* Додає `.worktrees/` до локального Git exclude без tracked-змін у consumer.
|
|
719
|
+
* Це не замінює repository Vitest excludes, але не лишає root checkout dirty
|
|
720
|
+
* через керовані або forensic worktree.
|
|
721
|
+
* @param {string} cwd корінь repository
|
|
722
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
723
|
+
* @returns {boolean} чи локальний exclude було змінено
|
|
724
|
+
*/
|
|
725
|
+
export function ensureLocalWorktreeExclude(cwd, spawnFn = spawnSync) {
|
|
726
|
+
try {
|
|
727
|
+
const excludePath = git(['rev-parse', '--git-path', 'info/exclude'], cwd, spawnFn).stdout.trim()
|
|
728
|
+
if (!excludePath) return false
|
|
729
|
+
const absoluteExcludePath = isAbsolute(excludePath) ? excludePath : join(cwd, excludePath)
|
|
730
|
+
const existing = existsSync(absoluteExcludePath) ? readFileSync(absoluteExcludePath, 'utf8') : ''
|
|
731
|
+
if (existing.split('\n').some(line => line.trim() === '.worktrees/')) return false
|
|
732
|
+
const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : ''
|
|
733
|
+
appendFileSync(absoluteExcludePath, `${separator}.worktrees/\n`)
|
|
734
|
+
return true
|
|
735
|
+
} catch {
|
|
736
|
+
return false
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
658
740
|
/**
|
|
659
741
|
* Прибирає reconciliation worktree або fail-closed лишає source неочищеним.
|
|
660
742
|
* @param {{branch:string,cwd:string}} worktree створений worktree
|
|
@@ -811,7 +893,9 @@ export function sourceDirectories(paths) {
|
|
|
811
893
|
* @returns {string[]} directories
|
|
812
894
|
*/
|
|
813
895
|
function changedPaths(cwd, spawnFn) {
|
|
814
|
-
const tracked = git(['diff', '--name-only', policyBaseRef(cwd), '--'], cwd, spawnFn)
|
|
896
|
+
const tracked = git(['diff', '--name-only', policyBaseRef(cwd), '--'], cwd, spawnFn)
|
|
897
|
+
.stdout.split('\n')
|
|
898
|
+
.filter(Boolean)
|
|
815
899
|
const untracked = git(['ls-files', '--others', '--exclude-standard'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
|
|
816
900
|
return [...new Set([...tracked, ...untracked])]
|
|
817
901
|
}
|
|
@@ -859,19 +943,67 @@ function validateScopedProjectGates(cwd, spawnFn, onProgress = noop) {
|
|
|
859
943
|
allowFailure: true
|
|
860
944
|
})
|
|
861
945
|
if (docs.status !== 0) {
|
|
862
|
-
return {
|
|
946
|
+
return {
|
|
947
|
+
ok: false,
|
|
948
|
+
error: `doc-files (${path}): ${docs.stderr || docs.stdout}`,
|
|
949
|
+
remediation: 'canonical-fixers'
|
|
950
|
+
}
|
|
863
951
|
}
|
|
864
952
|
onProgress(`scoped lint (${path})`)
|
|
865
953
|
const lint = run('npx', ['@7n/rules', 'lint', '--path', path, '--no-fix'], cwd, spawnFn, {
|
|
866
954
|
allowFailure: true
|
|
867
955
|
})
|
|
868
956
|
if (lint.status !== 0) {
|
|
869
|
-
return {
|
|
957
|
+
return {
|
|
958
|
+
ok: false,
|
|
959
|
+
error: `scoped lint (${path}): ${lint.stderr || lint.stdout}`,
|
|
960
|
+
remediation: 'canonical-fixers'
|
|
961
|
+
}
|
|
870
962
|
}
|
|
871
963
|
}
|
|
872
964
|
return { ok: true }
|
|
873
965
|
}
|
|
874
966
|
|
|
967
|
+
/**
|
|
968
|
+
* Запускає canonical fixers у worktree до ескалації min→max. Це прибирає
|
|
969
|
+
* formatting/CSpell/doc/changelog дефекти без повторного behavioral LLM.
|
|
970
|
+
* @param {string} cwd worktree
|
|
971
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
972
|
+
* @param {{remediation?:string}} validation провалена validation
|
|
973
|
+
* @param {(stage:string)=>void} [onProgress] stage callback
|
|
974
|
+
* @returns {{attempted:boolean,ok:boolean,error?:string}} результат
|
|
975
|
+
*/
|
|
976
|
+
export function remediateBehaviorState(cwd, spawnFn = spawnSync, validation = {}, onProgress = noop) {
|
|
977
|
+
if (validation.remediation !== 'canonical-fixers') return { attempted: false, ok: false }
|
|
978
|
+
|
|
979
|
+
for (const path of changedSourceDirectories(cwd, spawnFn)) {
|
|
980
|
+
onProgress(`deterministic fix (${path})`)
|
|
981
|
+
const fixed = run('npx', ['@7n/rules', 'lint', '--path', path], cwd, spawnFn, {
|
|
982
|
+
allowFailure: true
|
|
983
|
+
})
|
|
984
|
+
if (fixed.status !== 0) {
|
|
985
|
+
return {
|
|
986
|
+
attempted: true,
|
|
987
|
+
ok: false,
|
|
988
|
+
error: `canonical fix (${path}): ${fixed.stderr || fixed.stdout}`
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
onProgress('deterministic changelog fix')
|
|
994
|
+
const changelog = run('npx', ['@7n/rules', 'lint', 'changelog'], cwd, spawnFn, {
|
|
995
|
+
allowFailure: true
|
|
996
|
+
})
|
|
997
|
+
if (changelog.status !== 0) {
|
|
998
|
+
return {
|
|
999
|
+
attempted: true,
|
|
1000
|
+
ok: false,
|
|
1001
|
+
error: `canonical changelog fix: ${changelog.stderr || changelog.stdout}`
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
return { attempted: true, ok: true }
|
|
1005
|
+
}
|
|
1006
|
+
|
|
875
1007
|
/**
|
|
876
1008
|
* Встановлює frozen Bun dependencies, якщо новий worktree їх ще не має.
|
|
877
1009
|
* @param {string} cwd worktree
|
|
@@ -947,7 +1079,11 @@ export function validateBehaviorState(cwd, spawnFn = spawnSync, baseline = null,
|
|
|
947
1079
|
onProgress('changelog')
|
|
948
1080
|
const changelog = run('npx', ['@7n/rules', 'lint', 'changelog', '--no-fix'], cwd, spawnFn, { allowFailure: true })
|
|
949
1081
|
if (changelog.status !== 0) {
|
|
950
|
-
return {
|
|
1082
|
+
return {
|
|
1083
|
+
ok: false,
|
|
1084
|
+
error: `changelog gate: ${changelog.stderr || changelog.stdout}`,
|
|
1085
|
+
remediation: 'canonical-fixers'
|
|
1086
|
+
}
|
|
951
1087
|
}
|
|
952
1088
|
return { ok: true }
|
|
953
1089
|
}
|
|
@@ -1064,7 +1200,9 @@ async function finalizeBehavior(args) {
|
|
|
1064
1200
|
label: `behavior ${source}`,
|
|
1065
1201
|
onAttempt: ({ tier }) => onProgress('behavior validation', tier),
|
|
1066
1202
|
validate: () =>
|
|
1067
|
-
validateBehaviorState(worktreeCwd, spawnFn, baseline, step => onProgress(`behavior validation: ${step}`))
|
|
1203
|
+
validateBehaviorState(worktreeCwd, spawnFn, baseline, step => onProgress(`behavior validation: ${step}`)),
|
|
1204
|
+
remediate: validation =>
|
|
1205
|
+
remediateBehaviorState(worktreeCwd, spawnFn, validation, step => onProgress(`behavior validation: ${step}`))
|
|
1068
1206
|
})
|
|
1069
1207
|
if (!outcome.ok) throw new Error(`LLM behavioral verification: ${outcome.error}`)
|
|
1070
1208
|
return outcome.text.slice(0, PROMPT_TEXT_LIMIT)
|
|
@@ -1325,69 +1463,130 @@ async function triageCandidates(args) {
|
|
|
1325
1463
|
}
|
|
1326
1464
|
|
|
1327
1465
|
/**
|
|
1328
|
-
* Матеріалізує
|
|
1466
|
+
* Матеріалізує одну PR-групу з наперед визначеним progress index.
|
|
1329
1467
|
* @param {object} args orchestration context
|
|
1330
|
-
* @returns {Promise<
|
|
1468
|
+
* @returns {Promise<object>} result одного source/group
|
|
1331
1469
|
*/
|
|
1332
|
-
async function
|
|
1333
|
-
const {
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1470
|
+
async function materializePrGroup(args) {
|
|
1471
|
+
const {
|
|
1472
|
+
decision,
|
|
1473
|
+
candidate,
|
|
1474
|
+
group,
|
|
1475
|
+
prIndex,
|
|
1476
|
+
prTotal,
|
|
1477
|
+
runner,
|
|
1478
|
+
rootCwd,
|
|
1479
|
+
baselineCache,
|
|
1480
|
+
deps,
|
|
1481
|
+
spawnFn,
|
|
1482
|
+
log,
|
|
1483
|
+
createPr,
|
|
1484
|
+
progress
|
|
1485
|
+
} = args
|
|
1486
|
+
const key = `pr-${prIndex}`
|
|
1487
|
+
const prefix = `${prIndex}/${prTotal} · ${decision.source}`
|
|
1488
|
+
progress.step(key, `${prefix} · worktree`)
|
|
1489
|
+
try {
|
|
1490
|
+
const result = await createPr({
|
|
1491
|
+
candidate: { ...candidate, rationale: decision.rationale },
|
|
1492
|
+
group,
|
|
1493
|
+
runner,
|
|
1494
|
+
rootCwd,
|
|
1495
|
+
baselineCache,
|
|
1496
|
+
deps,
|
|
1497
|
+
spawnFn,
|
|
1498
|
+
log,
|
|
1499
|
+
onProgress: (step, tier) => progress.step(key, `${prefix} · ${step}`, tier)
|
|
1500
|
+
})
|
|
1501
|
+
return {
|
|
1502
|
+
source: decision.source,
|
|
1503
|
+
...(candidate.oid && { oid: candidate.oid }),
|
|
1504
|
+
...result
|
|
1505
|
+
}
|
|
1506
|
+
} finally {
|
|
1507
|
+
progress.done(key)
|
|
1344
1508
|
}
|
|
1509
|
+
}
|
|
1345
1510
|
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
log,
|
|
1362
|
-
onProgress: (step, tier) => progress.step(key, `${prefix} · ${step}`, tier)
|
|
1363
|
-
})
|
|
1364
|
-
results.push({
|
|
1365
|
-
source: decision.source,
|
|
1366
|
-
...(candidate.oid && { oid: candidate.oid }),
|
|
1367
|
-
...result
|
|
1368
|
-
})
|
|
1369
|
-
} finally {
|
|
1370
|
-
progress.done(key)
|
|
1511
|
+
/**
|
|
1512
|
+
* Виконує async jobs із bounded concurrency та стабільним порядком output.
|
|
1513
|
+
* @param {Array<()=>Promise<object>>} jobs jobs
|
|
1514
|
+
* @param {number} concurrency одночасні jobs
|
|
1515
|
+
* @returns {Promise<object[]>} результати в порядку jobs
|
|
1516
|
+
*/
|
|
1517
|
+
export async function runWithConcurrency(jobs, concurrency) {
|
|
1518
|
+
const results = Array.from({ length: jobs.length })
|
|
1519
|
+
let next = 0
|
|
1520
|
+
/** Виконує наступні jobs до вичерпання спільної черги. */
|
|
1521
|
+
async function worker() {
|
|
1522
|
+
while (next < jobs.length) {
|
|
1523
|
+
const index = next
|
|
1524
|
+
next += 1
|
|
1525
|
+
results[index] = await jobs[index]()
|
|
1371
1526
|
}
|
|
1372
1527
|
}
|
|
1528
|
+
const workerCount = Math.min(jobs.length, Math.max(1, concurrency))
|
|
1529
|
+
await Promise.all(Array.from({ length: workerCount }, () => worker()))
|
|
1373
1530
|
return results
|
|
1374
1531
|
}
|
|
1375
1532
|
|
|
1376
1533
|
/**
|
|
1377
|
-
*
|
|
1534
|
+
* Нормалізує bounded concurrency PR-фази.
|
|
1535
|
+
* @param {unknown} value override/env value
|
|
1536
|
+
* @returns {number} 1..MAX_PR_CONCURRENCY
|
|
1537
|
+
*/
|
|
1538
|
+
export function normalizePrConcurrency(value) {
|
|
1539
|
+
const parsed = Math.trunc(Number(value))
|
|
1540
|
+
if (!Number.isFinite(parsed)) return DEFAULT_PR_CONCURRENCY
|
|
1541
|
+
return Math.min(MAX_PR_CONCURRENCY, Math.max(1, parsed))
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Перетворює validated decisions у PR/keep/drop results. Незалежні PR-групи
|
|
1546
|
+
* виконуються паралельно з bounded concurrency, cleanup стартує лише після
|
|
1547
|
+
* завершення всіх jobs.
|
|
1378
1548
|
* @param {object} args orchestration context
|
|
1379
1549
|
* @returns {Promise<{bySource:Map<string,object>,results:Array<object>}>} materialized state
|
|
1380
1550
|
*/
|
|
1381
1551
|
async function materializeDecisions(args) {
|
|
1382
|
-
const { decisions, candidates } = args
|
|
1552
|
+
const { decisions, candidates, prConcurrency, prTotal } = args
|
|
1383
1553
|
const bySource = new Map(candidates.map(candidate => [candidate.source, candidate]))
|
|
1384
|
-
const
|
|
1554
|
+
const resultSlots = []
|
|
1555
|
+
const jobs = []
|
|
1556
|
+
let prIndex = 0
|
|
1385
1557
|
for (const decision of decisions) {
|
|
1386
1558
|
const candidate = bySource.get(decision.source)
|
|
1387
1559
|
if (!candidate) continue
|
|
1388
|
-
|
|
1560
|
+
if (decision.action !== 'pr') {
|
|
1561
|
+
resultSlots.push({
|
|
1562
|
+
source: decision.source,
|
|
1563
|
+
status: decision.action === 'drop' ? 'drop-recommended' : 'kept',
|
|
1564
|
+
rationale: decision.rationale ?? '',
|
|
1565
|
+
...(decision.incomplete === true && { incomplete: true }),
|
|
1566
|
+
...(candidate.oid && { oid: candidate.oid })
|
|
1567
|
+
})
|
|
1568
|
+
continue
|
|
1569
|
+
}
|
|
1570
|
+
for (const group of decision.groups) {
|
|
1571
|
+
prIndex += 1
|
|
1572
|
+
const fixedIndex = prIndex
|
|
1573
|
+
const resultIndex = resultSlots.length
|
|
1574
|
+
resultSlots.push(null)
|
|
1575
|
+
jobs.push(async () => {
|
|
1576
|
+
resultSlots[resultIndex] = await materializePrGroup({
|
|
1577
|
+
...args,
|
|
1578
|
+
decision,
|
|
1579
|
+
candidate,
|
|
1580
|
+
group,
|
|
1581
|
+
prIndex: fixedIndex,
|
|
1582
|
+
prTotal
|
|
1583
|
+
})
|
|
1584
|
+
return resultSlots[resultIndex]
|
|
1585
|
+
})
|
|
1586
|
+
}
|
|
1389
1587
|
}
|
|
1390
|
-
|
|
1588
|
+
await runWithConcurrency(jobs, prConcurrency)
|
|
1589
|
+
return { bySource, results: resultSlots.filter(Boolean) }
|
|
1391
1590
|
}
|
|
1392
1591
|
|
|
1393
1592
|
/**
|
|
@@ -1476,14 +1675,20 @@ export async function runGitReconcileOrchestrator(options = {}) {
|
|
|
1476
1675
|
const inventoryFn = deps.inventoryRepository ?? inventoryRepository
|
|
1477
1676
|
const createPr = deps.createPullRequest ?? createPullRequest
|
|
1478
1677
|
const cleanup = deps.cleanupSource ?? cleanupSource
|
|
1479
|
-
const reporterFactory = deps.createProgressReporter ?? createProgressReporter
|
|
1480
|
-
const isTTY = options.isTTY ?? process.stdout.isTTY === true
|
|
1481
1678
|
const now = deps.now ?? (() => performance.now())
|
|
1679
|
+
const setIntervalFn = deps.setIntervalFn ?? setInterval
|
|
1680
|
+
const clearIntervalFn = deps.clearIntervalFn ?? clearInterval
|
|
1681
|
+
const heartbeatMs = deps.heartbeatMs ?? PROGRESS_HEARTBEAT_MS
|
|
1682
|
+
const prConcurrency = normalizePrConcurrency(deps.prConcurrency ?? env.N_GIT_RECONCILE_CONCURRENCY)
|
|
1482
1683
|
const baselineCache = new Map()
|
|
1483
1684
|
|
|
1484
1685
|
const inventoryStartedAt = now()
|
|
1485
1686
|
log('⏳ 1/4 inventory')
|
|
1486
1687
|
const inventory = inventoryFn(rootCwd, { spawnFn })
|
|
1688
|
+
if (deps.ensureLocalWorktreeExclude !== false) {
|
|
1689
|
+
const ensureExclude = deps.ensureLocalWorktreeExclude ?? ensureLocalWorktreeExclude
|
|
1690
|
+
if (ensureExclude(rootCwd, spawnFn)) log('🛡️ Додано `.worktrees/` до локального `.git/info/exclude`')
|
|
1691
|
+
}
|
|
1487
1692
|
const candidates = [...inventory.branches, ...inventory.stashes].filter(item => item.state === 'review')
|
|
1488
1693
|
log(
|
|
1489
1694
|
`✅ 1/4 inventory · ${elapsedLabel(inventoryStartedAt, now)} · ${inventory.branches.length} branches · ${inventory.stashes.length} stash`
|
|
@@ -1495,8 +1700,10 @@ export async function runGitReconcileOrchestrator(options = {}) {
|
|
|
1495
1700
|
unitLabel: 'triage-пакетів',
|
|
1496
1701
|
phase: '2/4 triage',
|
|
1497
1702
|
log,
|
|
1498
|
-
|
|
1499
|
-
|
|
1703
|
+
now,
|
|
1704
|
+
heartbeatMs,
|
|
1705
|
+
setIntervalFn,
|
|
1706
|
+
clearIntervalFn
|
|
1500
1707
|
})
|
|
1501
1708
|
const triageStartedAt = now()
|
|
1502
1709
|
let decisions
|
|
@@ -1523,11 +1730,12 @@ export async function runGitReconcileOrchestrator(options = {}) {
|
|
|
1523
1730
|
unitLabel: 'PR-груп',
|
|
1524
1731
|
phase: '3/4 PR',
|
|
1525
1732
|
log,
|
|
1526
|
-
|
|
1527
|
-
|
|
1733
|
+
now,
|
|
1734
|
+
heartbeatMs,
|
|
1735
|
+
setIntervalFn,
|
|
1736
|
+
clearIntervalFn
|
|
1528
1737
|
})
|
|
1529
1738
|
const prStartedAt = now()
|
|
1530
|
-
const prState = { index: 0, total: prTotal }
|
|
1531
1739
|
let materialized
|
|
1532
1740
|
try {
|
|
1533
1741
|
materialized = await materializeDecisions({
|
|
@@ -1541,7 +1749,8 @@ export async function runGitReconcileOrchestrator(options = {}) {
|
|
|
1541
1749
|
log,
|
|
1542
1750
|
createPr,
|
|
1543
1751
|
progress: prProgress,
|
|
1544
|
-
|
|
1752
|
+
prTotal,
|
|
1753
|
+
prConcurrency
|
|
1545
1754
|
})
|
|
1546
1755
|
} finally {
|
|
1547
1756
|
prProgress.stop()
|
|
@@ -1555,8 +1764,10 @@ export async function runGitReconcileOrchestrator(options = {}) {
|
|
|
1555
1764
|
unitLabel: 'джерел',
|
|
1556
1765
|
phase: '4/4 cleanup',
|
|
1557
1766
|
log,
|
|
1558
|
-
|
|
1559
|
-
|
|
1767
|
+
now,
|
|
1768
|
+
heartbeatMs,
|
|
1769
|
+
setIntervalFn,
|
|
1770
|
+
clearIntervalFn
|
|
1560
1771
|
})
|
|
1561
1772
|
const cleanupStartedAt = now()
|
|
1562
1773
|
const cleanupState = { index: 0 }
|