@7n/rules 1.49.2 → 1.49.4
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/test/coverage/fix-worker.mjs +12 -1
- package/skills/git-reconcile/SKILL.md +35 -4
- package/skills/git-reconcile/js/docs/orchestrate.md +58 -6
- package/skills/git-reconcile/js/orchestrate.mjs +810 -183
- package/skills/git-reconcile/main.json +1 -1
|
@@ -1,13 +1,78 @@
|
|
|
1
1
|
/** @see ./docs/orchestrate.md */
|
|
2
2
|
import { spawnSync } from 'node:child_process'
|
|
3
|
-
import { existsSync } from 'node:fs'
|
|
4
|
-
import { join } from 'node:path'
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { performance } from 'node:perf_hooks'
|
|
6
|
+
|
|
7
|
+
import { createProgressReporter } from '../../../scripts/lib/lint-surface/progress.mjs'
|
|
5
8
|
|
|
6
9
|
const BASE_REF = 'origin/main'
|
|
10
|
+
const LLM_TIERS = ['min', 'max']
|
|
7
11
|
const REVIEW_BATCH_SIZE = 10
|
|
8
12
|
const PROMPT_TEXT_LIMIT = 12_000
|
|
9
13
|
const SOURCE_BRANCH_PREFIX = 'branch:'
|
|
10
14
|
const SOURCE_STASH_PREFIX = 'stash:'
|
|
15
|
+
const CONTENT_CONFLICT_RE = /^CONFLICT \(.+?\): Merge conflict in (.+)$/
|
|
16
|
+
const MODIFY_DELETE_CONFLICT_RE = /^CONFLICT \(modify\/delete\): (.+?) deleted in /
|
|
17
|
+
const REF_HEADS_RE = /^refs\/heads\//
|
|
18
|
+
const REF_ORIGIN_RE = /^refs\/remotes\/origin\//
|
|
19
|
+
const RENAME_DELETE_CONFLICT_RE = /^CONFLICT \(rename\/delete\): .+? renamed to (.+?) in .+?, but deleted/
|
|
20
|
+
const SOURCE_CODE_RE = /\.(?:js|mjs|ts|vue|rs|py)$/
|
|
21
|
+
const WHITESPACE_RE = /\s+/
|
|
22
|
+
const REF_INVENTORY_FORMAT = ['%(refname)', '%00', '%(object', 'name)', '%00', '%(committer', 'date:iso-strict)'].join(
|
|
23
|
+
''
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
/** Порожній callback для опційного progress log. */
|
|
27
|
+
function noop() {
|
|
28
|
+
// Навмисно порожньо: caller не запросив progress output.
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Форматує elapsed time без удаваної точності.
|
|
33
|
+
* @param {number} startedAt початок у мілісекундах
|
|
34
|
+
* @param {() => number} now монотонне джерело часу
|
|
35
|
+
* @returns {string} коротка тривалість
|
|
36
|
+
*/
|
|
37
|
+
function elapsedLabel(startedAt, now) {
|
|
38
|
+
const elapsed = Math.max(0, now() - startedAt)
|
|
39
|
+
return elapsed < 1000 ? `${Math.round(elapsed)} ms` : `${(elapsed / 1000).toFixed(1)} s`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Створює точний progress reporter для однієї фази. У TTY тримає живий bar,
|
|
44
|
+
* у non-TTY додає незмінювані рядки початку етапів і завершених одиниць.
|
|
45
|
+
* @param {object} args параметри фази
|
|
46
|
+
* @returns {{step:(key:string,detail:string,tier?:string)=>void,done:(key:string)=>void,stop:()=>void}} reporter
|
|
47
|
+
*/
|
|
48
|
+
function createPhaseProgress(args) {
|
|
49
|
+
const { total, unitLabel, phase, log, isTTY, reporterFactory } = args
|
|
50
|
+
if (total === 0) return { step: noop, done: noop, stop: noop }
|
|
51
|
+
|
|
52
|
+
const baseLog = text => log(text.endsWith('\n') ? text.slice(0, -1) : text)
|
|
53
|
+
const reporter = reporterFactory({
|
|
54
|
+
total,
|
|
55
|
+
log: baseLog,
|
|
56
|
+
isTTY,
|
|
57
|
+
unitLabel,
|
|
58
|
+
withFixed: false
|
|
59
|
+
})
|
|
60
|
+
let lastNonTtyLabel = ''
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
step: (_key, detail, tier) => {
|
|
64
|
+
const label = `${phase} · ${detail}`
|
|
65
|
+
reporter.concernStart(label, tier)
|
|
66
|
+
const rendered = tier ? `${label} · ${tier}` : label
|
|
67
|
+
if (!isTTY && rendered !== lastNonTtyLabel) {
|
|
68
|
+
log(`⏳ ${rendered}`)
|
|
69
|
+
lastNonTtyLabel = rendered
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
done: key => reporter.concernDone(key),
|
|
73
|
+
stop: () => reporter.stop()
|
|
74
|
+
}
|
|
75
|
+
}
|
|
11
76
|
|
|
12
77
|
/**
|
|
13
78
|
* Виконує команду без shell-інтерполяції.
|
|
@@ -19,11 +84,13 @@ const SOURCE_STASH_PREFIX = 'stash:'
|
|
|
19
84
|
* @returns {{ status: number, stdout: string, stderr: string }} результат
|
|
20
85
|
*/
|
|
21
86
|
function run(command, args, cwd, spawnFn, options = {}) {
|
|
87
|
+
const env = { ...process.env, GIT_EDITOR: 'true' }
|
|
88
|
+
if (command === 'npx') delete env.npm_config_package
|
|
22
89
|
const result = spawnFn(command, args, {
|
|
23
90
|
cwd,
|
|
24
91
|
encoding: 'utf8',
|
|
25
92
|
input: options.input,
|
|
26
|
-
env
|
|
93
|
+
env,
|
|
27
94
|
maxBuffer: 16 * 1024 * 1024
|
|
28
95
|
})
|
|
29
96
|
const normalized = {
|
|
@@ -66,19 +133,30 @@ function parseJson(text, fallback) {
|
|
|
66
133
|
}
|
|
67
134
|
|
|
68
135
|
/**
|
|
69
|
-
* Парсить
|
|
136
|
+
* Парсить branch refs і всі checkout HEAD OID, включно з detached worktree.
|
|
70
137
|
* @param {string} text porcelain
|
|
71
|
-
* @returns {Map<string,
|
|
138
|
+
* @returns {{branches:Map<string,string>,commits:Map<string,string>}} захищені checkout
|
|
72
139
|
*/
|
|
73
|
-
|
|
74
|
-
const
|
|
140
|
+
function parseWorktreeState(text) {
|
|
141
|
+
const branches = new Map()
|
|
142
|
+
const commits = new Map()
|
|
75
143
|
let path = ''
|
|
76
144
|
for (const line of text.split('\n')) {
|
|
77
145
|
if (line.startsWith('worktree ')) path = line.slice('worktree '.length)
|
|
78
|
-
if (line.startsWith('
|
|
146
|
+
if (line.startsWith('HEAD ')) commits.set(line.slice('HEAD '.length), path)
|
|
147
|
+
if (line.startsWith('branch ')) branches.set(line.slice('branch '.length), path)
|
|
79
148
|
if (line.length === 0) path = ''
|
|
80
149
|
}
|
|
81
|
-
return
|
|
150
|
+
return { branches, commits }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Парсить `git worktree list --porcelain` у branch→path.
|
|
155
|
+
* @param {string} text porcelain
|
|
156
|
+
* @returns {Map<string, string>} повний ref гілки → checkout
|
|
157
|
+
*/
|
|
158
|
+
export function parseWorktrees(text) {
|
|
159
|
+
return parseWorktreeState(text).branches
|
|
82
160
|
}
|
|
83
161
|
|
|
84
162
|
/**
|
|
@@ -87,7 +165,7 @@ export function parseWorktrees(text) {
|
|
|
87
165
|
* @returns {string} коротке ім'я
|
|
88
166
|
*/
|
|
89
167
|
function branchName(ref) {
|
|
90
|
-
return ref.replace(
|
|
168
|
+
return ref.replace(REF_HEADS_RE, '').replace(REF_ORIGIN_RE, '')
|
|
91
169
|
}
|
|
92
170
|
|
|
93
171
|
/**
|
|
@@ -95,14 +173,15 @@ function branchName(ref) {
|
|
|
95
173
|
* worktree-protection локального ref переноситься у запис.
|
|
96
174
|
* @param {Array<{ref:string, oid:string, date:string}>} refs сирі refs
|
|
97
175
|
* @param {Map<string,string>} worktrees branch→path
|
|
176
|
+
* @param {Map<string,string>} worktreeCommits checkout HEAD OID→path
|
|
98
177
|
* @returns {Array<{ref:string, oid:string, date:string, worktree:string|null,aliases:string[]}>} refs
|
|
99
178
|
*/
|
|
100
|
-
export function dedupeRefs(refs, worktrees) {
|
|
179
|
+
export function dedupeRefs(refs, worktrees, worktreeCommits = new Map()) {
|
|
101
180
|
const byOid = new Map()
|
|
102
181
|
for (const item of refs) {
|
|
103
182
|
if (item.ref === 'refs/remotes/origin/HEAD' || branchName(item.ref) === 'main') continue
|
|
104
183
|
const existing = byOid.get(item.oid)
|
|
105
|
-
const worktree = worktrees.get(item.ref) ?? existing?.worktree ?? null
|
|
184
|
+
const worktree = worktrees.get(item.ref) ?? worktreeCommits.get(item.oid) ?? existing?.worktree ?? null
|
|
106
185
|
const isRemote = item.ref.startsWith('refs/remotes/origin/')
|
|
107
186
|
const aliases = [...new Set([...(existing?.aliases ?? []), item.ref])].toSorted()
|
|
108
187
|
if (!existing || isRemote) {
|
|
@@ -114,7 +193,23 @@ export function dedupeRefs(refs, worktrees) {
|
|
|
114
193
|
existing.aliases = aliases
|
|
115
194
|
}
|
|
116
195
|
}
|
|
117
|
-
return
|
|
196
|
+
return byOid
|
|
197
|
+
.values()
|
|
198
|
+
.toArray()
|
|
199
|
+
.toSorted((a, b) => a.ref.localeCompare(b.ref))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Класифікує branch лише за вже зібраними Git-фактами.
|
|
204
|
+
* @param {{merged:boolean,novelCommitIds:string[],pr:object|null,worktree:string|null}} facts факти
|
|
205
|
+
* @returns {string} state
|
|
206
|
+
*/
|
|
207
|
+
function branchState({ merged, novelCommitIds, pr, worktree }) {
|
|
208
|
+
if (merged) return 'merged'
|
|
209
|
+
if (novelCommitIds.length === 0) return 'patch-equivalent'
|
|
210
|
+
if (pr) return 'open-pr'
|
|
211
|
+
if (worktree) return 'protected'
|
|
212
|
+
return 'review'
|
|
118
213
|
}
|
|
119
214
|
|
|
120
215
|
/**
|
|
@@ -138,13 +233,13 @@ function openPullRequests(cwd, spawnFn) {
|
|
|
138
233
|
|
|
139
234
|
/**
|
|
140
235
|
* Збирає compact commit metadata лише для patch-унікальних non-merge комітів.
|
|
141
|
-
* @param {string[]}
|
|
236
|
+
* @param {string[]} commitIds commit ids
|
|
142
237
|
* @param {string} cwd корінь
|
|
143
238
|
* @param {typeof spawnSync} spawnFn інжект
|
|
144
239
|
* @returns {Array<{oid:string,subject:string}>} коміти
|
|
145
240
|
*/
|
|
146
|
-
function commitMetadata(
|
|
147
|
-
return
|
|
241
|
+
function commitMetadata(commitIds, cwd, spawnFn) {
|
|
242
|
+
return commitIds.map(oid => {
|
|
148
243
|
const subject = git(['show', '-s', '--format=%s', oid], cwd, spawnFn).stdout.trim()
|
|
149
244
|
return { oid, subject }
|
|
150
245
|
})
|
|
@@ -158,9 +253,9 @@ function commitMetadata(oids, cwd, spawnFn) {
|
|
|
158
253
|
export function conflictFiles(text) {
|
|
159
254
|
const files = new Set()
|
|
160
255
|
for (const line of text.split('\n')) {
|
|
161
|
-
const content = line.match(
|
|
162
|
-
const rename = line.match(
|
|
163
|
-
const modifyDelete = line.match(
|
|
256
|
+
const content = line.match(CONTENT_CONFLICT_RE)
|
|
257
|
+
const rename = line.match(RENAME_DELETE_CONFLICT_RE)
|
|
258
|
+
const modifyDelete = line.match(MODIFY_DELETE_CONFLICT_RE)
|
|
164
259
|
const path = content?.[1] ?? rename?.[1] ?? modifyDelete?.[1]
|
|
165
260
|
if (path) files.add(path)
|
|
166
261
|
}
|
|
@@ -179,25 +274,21 @@ export function inventoryRepository(cwd, deps = {}) {
|
|
|
179
274
|
git(['fetch', '--prune', 'origin'], cwd, spawnFn)
|
|
180
275
|
git(['rev-parse', '--verify', BASE_REF], cwd, spawnFn)
|
|
181
276
|
|
|
182
|
-
const
|
|
277
|
+
const worktreeState = parseWorktreeState(git(['worktree', 'list', '--porcelain'], cwd, spawnFn).stdout)
|
|
183
278
|
const refLines = git(
|
|
184
|
-
[
|
|
185
|
-
'for-each-ref',
|
|
186
|
-
'--format=%(refname)%00%(objectname)%00%(committerdate:iso-strict)',
|
|
187
|
-
'refs/heads',
|
|
188
|
-
'refs/remotes/origin'
|
|
189
|
-
],
|
|
279
|
+
['for-each-ref', `--format=${REF_INVENTORY_FORMAT}`, 'refs/heads', 'refs/remotes/origin'],
|
|
190
280
|
cwd,
|
|
191
281
|
spawnFn
|
|
192
|
-
)
|
|
193
|
-
.split('\n')
|
|
282
|
+
)
|
|
283
|
+
.stdout.split('\n')
|
|
194
284
|
.filter(Boolean)
|
|
195
285
|
const refs = dedupeRefs(
|
|
196
286
|
refLines.map(line => {
|
|
197
287
|
const [ref, oid, date] = line.split('\0')
|
|
198
288
|
return { ref, oid, date }
|
|
199
289
|
}),
|
|
200
|
-
|
|
290
|
+
worktreeState.branches,
|
|
291
|
+
worktreeState.commits
|
|
201
292
|
)
|
|
202
293
|
const prs = openPullRequests(cwd, spawnFn)
|
|
203
294
|
const warnings = []
|
|
@@ -205,44 +296,31 @@ export function inventoryRepository(cwd, deps = {}) {
|
|
|
205
296
|
|
|
206
297
|
const branches = refs.map(item => {
|
|
207
298
|
const name = branchName(item.ref)
|
|
208
|
-
const merged =
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
299
|
+
const merged =
|
|
300
|
+
git(['merge-base', '--is-ancestor', item.ref, BASE_REF], cwd, spawnFn, {
|
|
301
|
+
allowFailure: true
|
|
302
|
+
}).status === 0
|
|
303
|
+
const novelCommitIds = merged
|
|
212
304
|
? []
|
|
213
|
-
: git(
|
|
214
|
-
|
|
215
|
-
cwd,
|
|
216
|
-
spawnFn
|
|
217
|
-
).stdout
|
|
218
|
-
.split('\n')
|
|
305
|
+
: git(['rev-list', '--right-only', '--cherry-pick', '--no-merges', `${BASE_REF}...${item.ref}`], cwd, spawnFn)
|
|
306
|
+
.stdout.split('\n')
|
|
219
307
|
.filter(Boolean)
|
|
220
308
|
.toReversed()
|
|
221
309
|
const counts = git(['rev-list', '--left-right', '--count', `${BASE_REF}...${item.ref}`], cwd, spawnFn)
|
|
222
310
|
.stdout.trim()
|
|
223
|
-
.split(
|
|
311
|
+
.split(WHITESPACE_RE)
|
|
224
312
|
.map(Number)
|
|
225
313
|
const pr = prs.get(name) ?? null
|
|
226
|
-
const state = merged
|
|
227
|
-
? 'merged'
|
|
228
|
-
: novelOids.length === 0
|
|
229
|
-
? 'patch-equivalent'
|
|
230
|
-
: pr
|
|
231
|
-
? 'open-pr'
|
|
232
|
-
: item.worktree
|
|
233
|
-
? 'protected'
|
|
234
|
-
: 'review'
|
|
314
|
+
const state = branchState({ merged, novelCommitIds, pr, worktree: item.worktree })
|
|
235
315
|
const changedFiles =
|
|
236
316
|
state === 'review'
|
|
237
|
-
? git(['diff', '--name-status', `${BASE_REF}...${item.ref}`], cwd, spawnFn)
|
|
238
|
-
.split('\n')
|
|
317
|
+
? git(['diff', '--name-status', `${BASE_REF}...${item.ref}`], cwd, spawnFn)
|
|
318
|
+
.stdout.split('\n')
|
|
239
319
|
.filter(Boolean)
|
|
240
320
|
.slice(0, 200)
|
|
241
321
|
: []
|
|
242
322
|
const mergeTree =
|
|
243
|
-
state === 'review'
|
|
244
|
-
? git(['merge-tree', BASE_REF, item.ref], cwd, spawnFn, { allowFailure: true }).stdout
|
|
245
|
-
: ''
|
|
323
|
+
state === 'review' ? git(['merge-tree', BASE_REF, item.ref], cwd, spawnFn, { allowFailure: true }).stdout : ''
|
|
246
324
|
return {
|
|
247
325
|
source: `${SOURCE_BRANCH_PREFIX}${item.ref}`,
|
|
248
326
|
ref: item.ref,
|
|
@@ -254,20 +332,16 @@ export function inventoryRepository(cwd, deps = {}) {
|
|
|
254
332
|
pr,
|
|
255
333
|
behind: counts[0] ?? 0,
|
|
256
334
|
ahead: counts[1] ?? 0,
|
|
257
|
-
commits: commitMetadata(
|
|
335
|
+
commits: commitMetadata(novelCommitIds, cwd, spawnFn),
|
|
258
336
|
changedFiles,
|
|
259
337
|
conflicts: conflictFiles(mergeTree)
|
|
260
338
|
}
|
|
261
339
|
})
|
|
262
340
|
|
|
263
|
-
const stashRows = git(['stash', 'list', '--format=%gd%x00%H%x00%gs'], cwd, spawnFn).stdout
|
|
264
|
-
.split('\n')
|
|
265
|
-
.filter(Boolean)
|
|
341
|
+
const stashRows = git(['stash', 'list', '--format=%gd%x00%H%x00%gs'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
|
|
266
342
|
const stashes = stashRows.map(line => {
|
|
267
343
|
const [ref, oid, subject] = line.split('\0')
|
|
268
|
-
const changedFiles = git(['stash', 'show', '--name-status', ref], cwd, spawnFn).stdout
|
|
269
|
-
.split('\n')
|
|
270
|
-
.filter(Boolean)
|
|
344
|
+
const changedFiles = git(['stash', 'show', '--name-status', ref], cwd, spawnFn).stdout.split('\n').filter(Boolean)
|
|
271
345
|
return {
|
|
272
346
|
source: `${SOURCE_STASH_PREFIX}${ref}`,
|
|
273
347
|
ref,
|
|
@@ -312,7 +386,12 @@ export function buildTriagePrompt(candidates, task = '') {
|
|
|
312
386
|
*/
|
|
313
387
|
export function parseDecisionEnvelope(text) {
|
|
314
388
|
const trimmed = text.trim()
|
|
315
|
-
|
|
389
|
+
let unfenced = trimmed
|
|
390
|
+
if (unfenced.startsWith('```')) {
|
|
391
|
+
const firstLineEnd = unfenced.indexOf('\n')
|
|
392
|
+
unfenced = firstLineEnd === -1 ? '' : unfenced.slice(firstLineEnd + 1)
|
|
393
|
+
if (unfenced.endsWith('```')) unfenced = unfenced.slice(0, -3).trim()
|
|
394
|
+
}
|
|
316
395
|
const first = unfenced.indexOf('{')
|
|
317
396
|
const last = unfenced.lastIndexOf('}')
|
|
318
397
|
if (first === -1 || last <= first) return null
|
|
@@ -326,9 +405,10 @@ export function parseDecisionEnvelope(text) {
|
|
|
326
405
|
* @param {string} prompt промпт
|
|
327
406
|
* @param {string} cwd робочий каталог
|
|
328
407
|
* @param {object} deps інжекти
|
|
408
|
+
* @param {'min'|'max'} [tier] model tier
|
|
329
409
|
* @returns {Promise<{ok:boolean,text:string,error:string|null}>} результат
|
|
330
410
|
*/
|
|
331
|
-
export async function callRunner(runner, prompt, cwd, deps = {}) {
|
|
411
|
+
export async function callRunner(runner, prompt, cwd, deps = {}, tier = 'max') {
|
|
332
412
|
if (runner === 'pi') {
|
|
333
413
|
let runAgentSkill = deps.runAgentSkill
|
|
334
414
|
if (!runAgentSkill) {
|
|
@@ -338,7 +418,7 @@ export async function callRunner(runner, prompt, cwd, deps = {}) {
|
|
|
338
418
|
let text = ''
|
|
339
419
|
const result = await runAgentSkill(prompt, {
|
|
340
420
|
skillId: 'git-reconcile',
|
|
341
|
-
tier
|
|
421
|
+
tier,
|
|
342
422
|
cwd,
|
|
343
423
|
deps: { out: chunk => (text += chunk) }
|
|
344
424
|
})
|
|
@@ -351,13 +431,145 @@ export async function callRunner(runner, prompt, cwd, deps = {}) {
|
|
|
351
431
|
runAcpAgent = module.runAcpAgent
|
|
352
432
|
}
|
|
353
433
|
try {
|
|
354
|
-
const text = await runAcpAgent(runner, prompt, cwd, { tier
|
|
434
|
+
const text = await runAcpAgent(runner, prompt, cwd, { tier })
|
|
355
435
|
return { ok: true, text, error: null }
|
|
356
436
|
} catch (error) {
|
|
357
437
|
return { ok: false, text: '', error: error instanceof Error ? error.message : String(error) }
|
|
358
438
|
}
|
|
359
439
|
}
|
|
360
440
|
|
|
441
|
+
/**
|
|
442
|
+
* Виконує bounded LLM-крок через min, валідовує результат JS-функцією і
|
|
443
|
+
* викликає max лише після конкретного провалу.
|
|
444
|
+
* @param {object} args параметри
|
|
445
|
+
* @returns {Promise<{ok:boolean,text:string,error:string|null,tier:'min'|'max',validation?:object,attempts:Array<object>}>} результат
|
|
446
|
+
*/
|
|
447
|
+
export async function callWithValidatedFallback(args) {
|
|
448
|
+
const { runner, prompt, cwd, validate, deps = {}, log = noop, label = 'LLM', onAttempt = noop } = args
|
|
449
|
+
const call = deps.callRunner ?? callRunner
|
|
450
|
+
const attempts = []
|
|
451
|
+
let retryPrompt = prompt
|
|
452
|
+
|
|
453
|
+
for (const tier of LLM_TIERS) {
|
|
454
|
+
onAttempt({ label, tier })
|
|
455
|
+
const outcome = await call(runner, retryPrompt, cwd, deps, tier)
|
|
456
|
+
if (!outcome.ok) {
|
|
457
|
+
const validation = { ok: false, error: outcome.error ?? `${tier} runner failed` }
|
|
458
|
+
attempts.push({ tier, ok: false, validation })
|
|
459
|
+
return { ...outcome, tier, validation, attempts }
|
|
460
|
+
}
|
|
461
|
+
const validation = await validate(outcome)
|
|
462
|
+
attempts.push({ tier, ok: outcome.ok, validation })
|
|
463
|
+
if (validation.ok) {
|
|
464
|
+
return { ...outcome, tier, validation, attempts }
|
|
465
|
+
}
|
|
466
|
+
if (tier === 'min') {
|
|
467
|
+
const reason = (validation.error ?? outcome.error ?? 'невідома помилка validation').slice(0, PROMPT_TEXT_LIMIT)
|
|
468
|
+
log(`↗ ${label}: min не пройшов validation (${reason}); повторюю на max`)
|
|
469
|
+
retryPrompt = [
|
|
470
|
+
prompt,
|
|
471
|
+
`Попередня min-спроба не пройшла deterministic validation: ${reason}.`,
|
|
472
|
+
'Виправ причину validation; не розширюй scope.'
|
|
473
|
+
].join('\n\n')
|
|
474
|
+
} else {
|
|
475
|
+
return {
|
|
476
|
+
ok: false,
|
|
477
|
+
text: outcome.text,
|
|
478
|
+
error: validation.error ?? outcome.error ?? 'max validation failed',
|
|
479
|
+
tier,
|
|
480
|
+
validation,
|
|
481
|
+
attempts
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
throw new Error('Недосяжний стан LLM fallback')
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Перевіряє branch groups та їх commit OID.
|
|
491
|
+
* @param {object} decision LLM decision
|
|
492
|
+
* @param {object} candidate inventory candidate
|
|
493
|
+
* @returns {string|null} validation error
|
|
494
|
+
*/
|
|
495
|
+
function validateBranchGroups(decision, candidate) {
|
|
496
|
+
const validCommitIds = new Set(candidate.commits?.map(commit => commit.oid))
|
|
497
|
+
const selectedCommitIds = new Set()
|
|
498
|
+
for (const group of decision.groups) {
|
|
499
|
+
if (!Array.isArray(group.commits) || group.commits.length === 0) {
|
|
500
|
+
return `branch group без commits для ${decision.source}`
|
|
501
|
+
}
|
|
502
|
+
if (group.commits.some(oid => !validCommitIds.has(oid))) {
|
|
503
|
+
return `branch group містить невідомий commit для ${decision.source}`
|
|
504
|
+
}
|
|
505
|
+
if (group.commits.some(oid => selectedCommitIds.has(oid))) {
|
|
506
|
+
return `commit повторюється між groups для ${decision.source}`
|
|
507
|
+
}
|
|
508
|
+
for (const oid of group.commits) selectedCommitIds.add(oid)
|
|
509
|
+
}
|
|
510
|
+
return null
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Перевіряє groups одного PR-рішення.
|
|
515
|
+
* @param {object} decision LLM decision
|
|
516
|
+
* @param {object} candidate inventory candidate
|
|
517
|
+
* @returns {string|null} validation error
|
|
518
|
+
*/
|
|
519
|
+
function validatePrDecision(decision, candidate) {
|
|
520
|
+
if (!Array.isArray(decision.groups) || decision.groups.length === 0) {
|
|
521
|
+
return `pr без groups для ${decision.source}`
|
|
522
|
+
}
|
|
523
|
+
if (decision.groups.some(group => typeof group.title !== 'string' || group.title.trim().length === 0)) {
|
|
524
|
+
return `pr group без title для ${decision.source}`
|
|
525
|
+
}
|
|
526
|
+
if (!decision.source.startsWith(SOURCE_BRANCH_PREFIX)) {
|
|
527
|
+
return decision.groups.length === 1 ? null : `неподільний stash має містити рівно одну pr group: ${decision.source}`
|
|
528
|
+
}
|
|
529
|
+
return validateBranchGroups(decision, candidate)
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Перевіряє одне triage-рішення.
|
|
534
|
+
* @param {object} decision LLM decision
|
|
535
|
+
* @param {object|undefined} candidate inventory candidate
|
|
536
|
+
* @param {Set<string>} seen уже оброблені source
|
|
537
|
+
* @returns {string|null} validation error
|
|
538
|
+
*/
|
|
539
|
+
function validateDecision(decision, candidate, seen) {
|
|
540
|
+
if (!candidate || seen.has(decision.source)) {
|
|
541
|
+
return `невідомий або дубльований source: ${decision.source}`
|
|
542
|
+
}
|
|
543
|
+
seen.add(decision.source)
|
|
544
|
+
if (!['pr', 'keep', 'drop'].includes(decision.action)) {
|
|
545
|
+
return `невалідний action для ${decision.source}`
|
|
546
|
+
}
|
|
547
|
+
return decision.action === 'pr' ? validatePrDecision(decision, candidate) : null
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Структурно перевіряє triage-рішення: рівно один verdict на candidate,
|
|
552
|
+
* валідні actions/groups і лише відомі commit OID.
|
|
553
|
+
* @param {{text:string}} outcome LLM output
|
|
554
|
+
* @param {Array<object>} candidates batch
|
|
555
|
+
* @returns {{ok:boolean,error?:string,value?:object}} validation
|
|
556
|
+
*/
|
|
557
|
+
export function validateTriageOutcome(outcome, candidates) {
|
|
558
|
+
const envelope = parseDecisionEnvelope(outcome.text)
|
|
559
|
+
if (!Array.isArray(envelope?.decisions)) return { ok: false, error: 'відсутній decisions array' }
|
|
560
|
+
if (envelope.decisions.length !== candidates.length) {
|
|
561
|
+
return { ok: false, error: 'кількість decisions не збігається з candidates' }
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const bySource = new Map(candidates.map(candidate => [candidate.source, candidate]))
|
|
565
|
+
const seen = new Set()
|
|
566
|
+
for (const decision of envelope.decisions) {
|
|
567
|
+
const error = validateDecision(decision, bySource.get(decision.source), seen)
|
|
568
|
+
if (error) return { ok: false, error }
|
|
569
|
+
}
|
|
570
|
+
return { ok: true, value: envelope }
|
|
571
|
+
}
|
|
572
|
+
|
|
361
573
|
/**
|
|
362
574
|
* Перетворює довільний title/ref на branch slug.
|
|
363
575
|
* @param {string} value title/ref
|
|
@@ -423,9 +635,191 @@ function createReconcileWorktree(title, source, cwd, spawnFn) {
|
|
|
423
635
|
* @returns {string[]} шляхи
|
|
424
636
|
*/
|
|
425
637
|
function unresolvedFiles(cwd, spawnFn) {
|
|
426
|
-
return git(['diff', '--name-only', '--diff-filter=U'], cwd, spawnFn).stdout
|
|
427
|
-
|
|
428
|
-
|
|
638
|
+
return git(['diff', '--name-only', '--diff-filter=U'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Пропускає лише підтверджений empty cherry-pick: sequencer активний,
|
|
643
|
+
* конфліктів немає, staged diff порожній.
|
|
644
|
+
* @param {string} cwd worktree
|
|
645
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
646
|
+
* @returns {boolean} чи виконано cherry-pick --skip
|
|
647
|
+
*/
|
|
648
|
+
export function skipEmptyCherryPick(cwd, spawnFn = spawnSync) {
|
|
649
|
+
const inProgress =
|
|
650
|
+
git(['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'], cwd, spawnFn, {
|
|
651
|
+
allowFailure: true
|
|
652
|
+
}).status === 0
|
|
653
|
+
if (!inProgress || unresolvedFiles(cwd, spawnFn).length > 0) return false
|
|
654
|
+
const stagedEmpty =
|
|
655
|
+
git(['diff', '--cached', '--quiet'], cwd, spawnFn, {
|
|
656
|
+
allowFailure: true
|
|
657
|
+
}).status === 0
|
|
658
|
+
if (!stagedEmpty) return false
|
|
659
|
+
git(['cherry-pick', '--skip'], cwd, spawnFn)
|
|
660
|
+
return true
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* Перевіряє, що agentic-крок залишив Git у консистентному стані.
|
|
665
|
+
* @param {string} cwd worktree
|
|
666
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
667
|
+
* @returns {{ok:boolean,error?:string}} validation
|
|
668
|
+
*/
|
|
669
|
+
function validateGitState(cwd, spawnFn) {
|
|
670
|
+
const unresolved = unresolvedFiles(cwd, spawnFn)
|
|
671
|
+
if (unresolved.length > 0) {
|
|
672
|
+
return { ok: false, error: `нерозв'язані конфлікти: ${unresolved.join(', ')}` }
|
|
673
|
+
}
|
|
674
|
+
const diffCheck = git(['diff', '--check'], cwd, spawnFn, { allowFailure: true })
|
|
675
|
+
if (diffCheck.status !== 0) {
|
|
676
|
+
return { ok: false, error: `git diff --check: ${diffCheck.stderr || diffCheck.stdout}` }
|
|
677
|
+
}
|
|
678
|
+
return { ok: true }
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/** Межі стабільного Vitest failure identifier. */
|
|
682
|
+
const ANSI_ESCAPE = String.fromCodePoint(27)
|
|
683
|
+
const TEST_FAILURE_PREFIX = 'FAIL '
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Витягає стабільні Vitest failure identifiers без summary/timing.
|
|
687
|
+
* @param {string} output stdout + stderr
|
|
688
|
+
* @returns {Set<string>} suite/test failures
|
|
689
|
+
*/
|
|
690
|
+
export function testFailureSignatures(output) {
|
|
691
|
+
const signatures = new Set()
|
|
692
|
+
for (const line of output.split('\n')) {
|
|
693
|
+
const start = line.indexOf(TEST_FAILURE_PREFIX)
|
|
694
|
+
if (start === -1) continue
|
|
695
|
+
const signature = line
|
|
696
|
+
.slice(start + TEST_FAILURE_PREFIX.length)
|
|
697
|
+
.split(ANSI_ESCAPE, 1)[0]
|
|
698
|
+
.trim()
|
|
699
|
+
if (signature) signatures.add(signature)
|
|
700
|
+
}
|
|
701
|
+
return signatures
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Дозволяє red baseline лише якщо після перенесення не з'явилось нових
|
|
706
|
+
* Vitest failures. Нерозпізнаний red output завжди fail-closed.
|
|
707
|
+
* @param {{status:number,stdout:string,stderr:string}|null} baseline до змін
|
|
708
|
+
* @param {{status:number,stdout:string,stderr:string}} current після змін
|
|
709
|
+
* @returns {boolean} чи test gate пройдено
|
|
710
|
+
*/
|
|
711
|
+
export function acceptsTestOutcome(baseline, current) {
|
|
712
|
+
if (current.status === 0) return true
|
|
713
|
+
if (!baseline || baseline.status === 0) return false
|
|
714
|
+
const before = testFailureSignatures(`${baseline.stdout}\n${baseline.stderr}`)
|
|
715
|
+
const after = testFailureSignatures(`${current.stdout}\n${current.stderr}`)
|
|
716
|
+
return before.size > 0 && after.size > 0 && [...after].every(signature => before.has(signature))
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Зводить змінені code paths до найвужчих директорій для scoped gates.
|
|
721
|
+
* @param {string[]} paths relative paths
|
|
722
|
+
* @returns {string[]} унікальні sorted directories
|
|
723
|
+
*/
|
|
724
|
+
export function sourceDirectories(paths) {
|
|
725
|
+
return [...new Set(paths.filter(path => SOURCE_CODE_RE.test(path)).map(path => dirname(path)))].toSorted()
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Збирає tracked і untracked code directories відносно origin/main.
|
|
730
|
+
* @param {string} cwd worktree
|
|
731
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
732
|
+
* @returns {string[]} directories
|
|
733
|
+
*/
|
|
734
|
+
function changedSourceDirectories(cwd, spawnFn) {
|
|
735
|
+
const tracked = git(['diff', '--name-only', BASE_REF, '--'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
|
|
736
|
+
const untracked = git(['ls-files', '--others', '--exclude-standard'], cwd, spawnFn).stdout.split('\n').filter(Boolean)
|
|
737
|
+
return sourceDirectories([...tracked, ...untracked]).filter(path => existsSync(join(cwd, path === '.' ? '' : path)))
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Генерує docs і запускає unified read-only lint лише в директоріях
|
|
742
|
+
* зміненого коду, не торкаючись repository-wide baseline.
|
|
743
|
+
* @param {string} cwd worktree
|
|
744
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
745
|
+
* @returns {{ok:boolean,error?:string}} gate
|
|
746
|
+
*/
|
|
747
|
+
function validateScopedProjectGates(cwd, spawnFn) {
|
|
748
|
+
for (const path of changedSourceDirectories(cwd, spawnFn)) {
|
|
749
|
+
const docs = run('npx', ['@7n/rules', 'lint', 'doc-files', '--path', path], cwd, spawnFn, {
|
|
750
|
+
allowFailure: true
|
|
751
|
+
})
|
|
752
|
+
if (docs.status !== 0) {
|
|
753
|
+
return { ok: false, error: `doc-files (${path}): ${docs.stderr || docs.stdout}` }
|
|
754
|
+
}
|
|
755
|
+
const lint = run('npx', ['@7n/rules', 'lint', '--path', path, '--no-fix'], cwd, spawnFn, {
|
|
756
|
+
allowFailure: true
|
|
757
|
+
})
|
|
758
|
+
if (lint.status !== 0) {
|
|
759
|
+
return { ok: false, error: `scoped lint (${path}): ${lint.stderr || lint.stdout}` }
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
return { ok: true }
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Встановлює frozen Bun dependencies, якщо новий worktree їх ще не має.
|
|
767
|
+
* @param {string} cwd worktree
|
|
768
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
769
|
+
*/
|
|
770
|
+
function ensureWorktreeDependencies(cwd, spawnFn) {
|
|
771
|
+
if (!existsSync(join(cwd, 'package.json')) || !existsSync(join(cwd, 'bun.lock'))) return
|
|
772
|
+
if (existsSync(join(cwd, 'node_modules'))) return
|
|
773
|
+
run('bun', ['install', '--frozen-lockfile'], cwd, spawnFn)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Фіксує test baseline на чистому origin/main до перенесення source.
|
|
778
|
+
* @param {string} cwd worktree
|
|
779
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
780
|
+
* @returns {{tests:{status:number,stdout:string,stderr:string}|null}} baseline
|
|
781
|
+
*/
|
|
782
|
+
export function captureBehaviorBaseline(cwd, spawnFn = spawnSync) {
|
|
783
|
+
ensureWorktreeDependencies(cwd, spawnFn)
|
|
784
|
+
const packageJsonPath = join(cwd, 'package.json')
|
|
785
|
+
if (!existsSync(packageJsonPath)) return { tests: null }
|
|
786
|
+
const packageJson = parseJson(readFileSync(packageJsonPath, 'utf8'), {})
|
|
787
|
+
const tests = packageJson?.scripts?.test ? run('bun', ['run', 'test'], cwd, spawnFn, { allowFailure: true }) : null
|
|
788
|
+
return { tests }
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* Додає до Git-state validation test script із репозиторію і changelog gate.
|
|
793
|
+
* Саме ці докази вирішують, чи приймати min-результат або ескалювати на max.
|
|
794
|
+
* @param {string} cwd worktree
|
|
795
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
796
|
+
* @param {{tests:{status:number,stdout:string,stderr:string}|null}|null} [baseline] стан origin/main
|
|
797
|
+
* @returns {{ok:boolean,error?:string}} validation
|
|
798
|
+
*/
|
|
799
|
+
export function validateBehaviorState(cwd, spawnFn = spawnSync, baseline = null) {
|
|
800
|
+
const gitState = validateGitState(cwd, spawnFn)
|
|
801
|
+
if (!gitState.ok) return gitState
|
|
802
|
+
const projectGates = validateScopedProjectGates(cwd, spawnFn)
|
|
803
|
+
if (!projectGates.ok) return projectGates
|
|
804
|
+
const postGateGitState = validateGitState(cwd, spawnFn)
|
|
805
|
+
if (!postGateGitState.ok) return postGateGitState
|
|
806
|
+
|
|
807
|
+
const packageJsonPath = join(cwd, 'package.json')
|
|
808
|
+
if (existsSync(packageJsonPath)) {
|
|
809
|
+
const packageJson = parseJson(readFileSync(packageJsonPath, 'utf8'), {})
|
|
810
|
+
if (packageJson?.scripts?.test) {
|
|
811
|
+
const tests = run('bun', ['run', 'test'], cwd, spawnFn, { allowFailure: true })
|
|
812
|
+
if (!acceptsTestOutcome(baseline?.tests ?? null, tests)) {
|
|
813
|
+
return { ok: false, error: `bun run test: ${tests.stderr || tests.stdout}` }
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const changelog = run('npx', ['@7n/rules', 'lint', 'changelog', '--no-fix'], cwd, spawnFn, { allowFailure: true })
|
|
819
|
+
if (changelog.status !== 0) {
|
|
820
|
+
return { ok: false, error: `changelog gate: ${changelog.stderr || changelog.stdout}` }
|
|
821
|
+
}
|
|
822
|
+
return { ok: true }
|
|
429
823
|
}
|
|
430
824
|
|
|
431
825
|
/**
|
|
@@ -433,20 +827,28 @@ function unresolvedFiles(cwd, spawnFn) {
|
|
|
433
827
|
* @param {object} args контекст
|
|
434
828
|
* @returns {Promise<void>}
|
|
435
829
|
*/
|
|
436
|
-
async function resolveConflict(
|
|
830
|
+
async function resolveConflict(args) {
|
|
831
|
+
const { runner, source, worktreeCwd, deps, spawnFn, log, onProgress = noop } = args
|
|
437
832
|
const unresolved = unresolvedFiles(worktreeCwd, spawnFn)
|
|
438
833
|
const prompt = [
|
|
439
834
|
`У worktree ${worktreeCwd} JS уже застосував ${source} до свіжого origin/main.`,
|
|
440
835
|
`Розв'яжи лише змістові конфлікти: ${unresolved.join(', ')}.`,
|
|
441
|
-
'Порівняй current main та намір перенесеної зміни; не використовуй ours/theirs
|
|
836
|
+
'Порівняй current main та намір перенесеної зміни; не використовуй ours/theirs механічно.',
|
|
442
837
|
'Збережи актуальну поведінку main, перенеси лише відсутню корисну частину.',
|
|
443
838
|
'За потреби онови regression test. Не commit, не push, не створюй PR і не видаляй refs.',
|
|
444
|
-
'Наприкінці прибери conflict markers і коротко
|
|
839
|
+
'Наприкінці прибери conflict markers і коротко наведи виконані перевірки.'
|
|
445
840
|
].join('\n\n')
|
|
446
|
-
const outcome = await
|
|
841
|
+
const outcome = await callWithValidatedFallback({
|
|
842
|
+
runner,
|
|
843
|
+
prompt,
|
|
844
|
+
cwd: worktreeCwd,
|
|
845
|
+
deps,
|
|
846
|
+
log,
|
|
847
|
+
label: `conflict ${source}`,
|
|
848
|
+
onAttempt: ({ tier }) => onProgress('resolve conflict', tier),
|
|
849
|
+
validate: () => validateGitState(worktreeCwd, spawnFn)
|
|
850
|
+
})
|
|
447
851
|
if (!outcome.ok) throw new Error(`LLM conflict resolution: ${outcome.error}`)
|
|
448
|
-
const remaining = unresolvedFiles(worktreeCwd, spawnFn)
|
|
449
|
-
if (remaining.length > 0) throw new Error(`Нерозв'язані конфлікти: ${remaining.join(', ')}`)
|
|
450
852
|
git(['add', '-A'], worktreeCwd, spawnFn)
|
|
451
853
|
}
|
|
452
854
|
|
|
@@ -455,15 +857,17 @@ async function resolveConflict({ runner, source, worktreeCwd, deps, spawnFn }) {
|
|
|
455
857
|
* @param {object} args контекст
|
|
456
858
|
* @returns {Promise<void>}
|
|
457
859
|
*/
|
|
458
|
-
async function applySource(
|
|
860
|
+
async function applySource(args) {
|
|
861
|
+
const { source, sourceOid, commits, runner, rootCwd, worktreeCwd, deps, spawnFn, log, onProgress = noop } = args
|
|
459
862
|
if (source.startsWith(SOURCE_BRANCH_PREFIX)) {
|
|
460
863
|
for (const oid of commits) {
|
|
461
864
|
const result = git(['cherry-pick', oid], worktreeCwd, spawnFn, { allowFailure: true })
|
|
462
865
|
if (result.status === 0) continue
|
|
463
866
|
if (unresolvedFiles(worktreeCwd, spawnFn).length === 0) {
|
|
867
|
+
if (skipEmptyCherryPick(worktreeCwd, spawnFn)) continue
|
|
464
868
|
throw new Error(`cherry-pick ${oid}: ${result.stderr || result.stdout}`)
|
|
465
869
|
}
|
|
466
|
-
await resolveConflict({ runner, source: oid, worktreeCwd, deps, spawnFn })
|
|
870
|
+
await resolveConflict({ runner, source: oid, worktreeCwd, deps, spawnFn, log, onProgress })
|
|
467
871
|
if (
|
|
468
872
|
git(['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'], worktreeCwd, spawnFn, {
|
|
469
873
|
allowFailure: true
|
|
@@ -485,7 +889,7 @@ async function applySource({ source, sourceOid, commits, runner, rootCwd, worktr
|
|
|
485
889
|
if (unresolvedFiles(worktreeCwd, spawnFn).length === 0) {
|
|
486
890
|
throw new Error(`git apply ${stashRef}: ${applied.stderr || applied.stdout}`)
|
|
487
891
|
}
|
|
488
|
-
await resolveConflict({ runner, source: stashRef, worktreeCwd, deps, spawnFn })
|
|
892
|
+
await resolveConflict({ runner, source: stashRef, worktreeCwd, deps, spawnFn, log, onProgress })
|
|
489
893
|
}
|
|
490
894
|
}
|
|
491
895
|
|
|
@@ -494,18 +898,28 @@ async function applySource({ source, sourceOid, commits, runner, rootCwd, worktr
|
|
|
494
898
|
* @param {object} args контекст
|
|
495
899
|
* @returns {Promise<string>} текст відповіді
|
|
496
900
|
*/
|
|
497
|
-
async function finalizeBehavior(
|
|
901
|
+
async function finalizeBehavior(args) {
|
|
902
|
+
const { runner, source, rationale, worktreeCwd, baseline, deps, spawnFn, log, onProgress = noop } = args
|
|
498
903
|
const prompt = [
|
|
499
904
|
`JS переніс ${source} на свіжий origin/main у ${worktreeCwd}.`,
|
|
500
905
|
`Очікувана користь: ${rationale}`,
|
|
501
906
|
'Перевір реальний diff і call sites. Доведи лише перенесену поведінку до готовності:',
|
|
502
907
|
'- додай/онови regression test, якщо це bug fix;',
|
|
503
908
|
'- виконай найвужчі релевантні тести;',
|
|
504
|
-
'- виконай repository-required docs/change checks;',
|
|
505
909
|
'- не роби unrelated refactor або formatting churn.',
|
|
910
|
+
'Не запускай full repository tests, doc generation, lint або changelog: після твоїх правок це детерміновано виконає JS.',
|
|
506
911
|
'Не commit, не push, не створюй PR і не видаляй refs. Якщо поведінку неможливо безпечно підтвердити — нічого не маскуй, поверни чіткий blocker.'
|
|
507
912
|
].join('\n\n')
|
|
508
|
-
const outcome = await
|
|
913
|
+
const outcome = await callWithValidatedFallback({
|
|
914
|
+
runner,
|
|
915
|
+
prompt,
|
|
916
|
+
cwd: worktreeCwd,
|
|
917
|
+
deps,
|
|
918
|
+
log,
|
|
919
|
+
label: `behavior ${source}`,
|
|
920
|
+
onAttempt: ({ tier }) => onProgress('behavior validation', tier),
|
|
921
|
+
validate: () => validateBehaviorState(worktreeCwd, spawnFn, baseline)
|
|
922
|
+
})
|
|
509
923
|
if (!outcome.ok) throw new Error(`LLM behavioral verification: ${outcome.error}`)
|
|
510
924
|
return outcome.text.slice(0, PROMPT_TEXT_LIMIT)
|
|
511
925
|
}
|
|
@@ -516,19 +930,24 @@ async function finalizeBehavior({ runner, source, rationale, worktreeCwd, deps }
|
|
|
516
930
|
* @param {object} args параметри
|
|
517
931
|
* @returns {Promise<{status:string,url?:string,branch?:string,error?:string,worktree?:string}>} результат
|
|
518
932
|
*/
|
|
519
|
-
async function createPullRequest(
|
|
933
|
+
async function createPullRequest(args) {
|
|
934
|
+
const { candidate, group, runner, rootCwd, deps, spawnFn, log, onProgress = noop } = args
|
|
520
935
|
const source = candidate.source
|
|
521
|
-
const
|
|
936
|
+
const validCommitIds = new Set(candidate.commits?.map(commit => commit.oid))
|
|
522
937
|
const commits = source.startsWith(SOURCE_BRANCH_PREFIX)
|
|
523
|
-
? (group.commits ?? []).filter(oid =>
|
|
938
|
+
? (group.commits ?? []).filter(oid => validCommitIds.has(oid))
|
|
524
939
|
: []
|
|
525
940
|
if (source.startsWith(SOURCE_BRANCH_PREFIX) && commits.length === 0) {
|
|
526
941
|
return { status: 'kept', error: 'LLM не вибрала жодного валідного commit oid' }
|
|
527
942
|
}
|
|
528
943
|
|
|
944
|
+
onProgress('worktree')
|
|
529
945
|
const worktree = createReconcileWorktree(group.title, source, rootCwd, spawnFn)
|
|
530
946
|
log(`🌿 ${source} → ${worktree.branch}`)
|
|
531
947
|
try {
|
|
948
|
+
onProgress('baseline tests')
|
|
949
|
+
const baseline = captureBehaviorBaseline(worktree.cwd, spawnFn)
|
|
950
|
+
onProgress('apply')
|
|
532
951
|
await applySource({
|
|
533
952
|
source,
|
|
534
953
|
sourceOid: candidate.oid,
|
|
@@ -537,39 +956,45 @@ async function createPullRequest({ candidate, group, runner, rootCwd, deps, spaw
|
|
|
537
956
|
rootCwd,
|
|
538
957
|
worktreeCwd: worktree.cwd,
|
|
539
958
|
deps,
|
|
540
|
-
spawnFn
|
|
959
|
+
spawnFn,
|
|
960
|
+
log,
|
|
961
|
+
onProgress
|
|
541
962
|
})
|
|
542
963
|
const verification = await finalizeBehavior({
|
|
543
964
|
runner,
|
|
544
965
|
source,
|
|
545
966
|
rationale: group.rationale ?? candidate.rationale ?? '',
|
|
546
967
|
worktreeCwd: worktree.cwd,
|
|
547
|
-
|
|
968
|
+
baseline,
|
|
969
|
+
deps,
|
|
970
|
+
spawnFn,
|
|
971
|
+
log,
|
|
972
|
+
onProgress
|
|
548
973
|
})
|
|
974
|
+
onProgress('Git validation')
|
|
549
975
|
const unresolved = unresolvedFiles(worktree.cwd, spawnFn)
|
|
550
976
|
if (unresolved.length > 0) throw new Error(`Нерозв'язані конфлікти: ${unresolved.join(', ')}`)
|
|
551
977
|
git(['diff', '--check'], worktree.cwd, spawnFn)
|
|
552
978
|
git(['add', '-A'], worktree.cwd, spawnFn)
|
|
553
|
-
const staged =
|
|
554
|
-
|
|
555
|
-
|
|
979
|
+
const staged =
|
|
980
|
+
git(['diff', '--cached', '--quiet'], worktree.cwd, spawnFn, {
|
|
981
|
+
allowFailure: true
|
|
982
|
+
}).status !== 0
|
|
556
983
|
if (staged) git(['commit', '-m', group.title], worktree.cwd, spawnFn)
|
|
557
984
|
const ahead = Number(git(['rev-list', '--count', `${BASE_REF}..HEAD`], worktree.cwd, spawnFn).stdout.trim())
|
|
558
985
|
if (!ahead) throw new Error('Після reconciliation немає змін відносно origin/main')
|
|
559
986
|
|
|
560
|
-
const changelog = run(
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
worktree.cwd,
|
|
564
|
-
spawnFn,
|
|
565
|
-
{ allowFailure: true }
|
|
566
|
-
)
|
|
987
|
+
const changelog = run('npx', ['@7n/rules', 'lint', 'changelog', '--no-fix'], worktree.cwd, spawnFn, {
|
|
988
|
+
allowFailure: true
|
|
989
|
+
})
|
|
567
990
|
if (changelog.status !== 0) {
|
|
568
991
|
throw new Error(`changelog gate: ${changelog.stderr || changelog.stdout}`)
|
|
569
992
|
}
|
|
570
993
|
git(['diff', '--check', `${BASE_REF}...HEAD`], worktree.cwd, spawnFn)
|
|
994
|
+
onProgress('push')
|
|
571
995
|
git(['push', '-u', 'origin', worktree.branch], worktree.cwd, spawnFn)
|
|
572
996
|
|
|
997
|
+
onProgress('create PR')
|
|
573
998
|
const body = [
|
|
574
999
|
`Джерело: \`${source}\`.`,
|
|
575
1000
|
'',
|
|
@@ -588,6 +1013,7 @@ async function createPullRequest({ candidate, group, runner, rootCwd, deps, spaw
|
|
|
588
1013
|
worktree.cwd,
|
|
589
1014
|
spawnFn
|
|
590
1015
|
).stdout.trim()
|
|
1016
|
+
onProgress('remove worktree')
|
|
591
1017
|
run('npx', ['@7n/mt', 'worktree', 'remove', worktree.branch], rootCwd, spawnFn, {
|
|
592
1018
|
allowFailure: true
|
|
593
1019
|
})
|
|
@@ -613,8 +1039,8 @@ async function createPullRequest({ candidate, group, runner, rootCwd, deps, spaw
|
|
|
613
1039
|
export function cleanupSource(candidate, rootCwd, spawnFn = spawnSync) {
|
|
614
1040
|
try {
|
|
615
1041
|
if (candidate.source.startsWith(SOURCE_STASH_PREFIX)) {
|
|
616
|
-
const rows = git(['stash', 'list', '--format=%gd%x00%H'], rootCwd, spawnFn)
|
|
617
|
-
.split('\n')
|
|
1042
|
+
const rows = git(['stash', 'list', '--format=%gd%x00%H'], rootCwd, spawnFn)
|
|
1043
|
+
.stdout.split('\n')
|
|
618
1044
|
.filter(Boolean)
|
|
619
1045
|
.map(line => line.split('\0'))
|
|
620
1046
|
const row = rows.find(([, oid]) => oid === candidate.oid)
|
|
@@ -648,19 +1074,20 @@ export function formatReport({ inventory, results }) {
|
|
|
648
1074
|
const lines = ['## git-reconcile: підсумок']
|
|
649
1075
|
for (const branch of inventory.branches) {
|
|
650
1076
|
if (branch.state === 'review') continue
|
|
651
|
-
|
|
652
|
-
|
|
1077
|
+
let suffix = ''
|
|
1078
|
+
if (branch.pr?.url) suffix = ` — ${branch.pr.url}`
|
|
1079
|
+
else if (branch.worktree) suffix = ` — ${branch.worktree}`
|
|
1080
|
+
const cleanupOid = branch.oid ? `; oid=${branch.oid}` : ''
|
|
1081
|
+
const cleanup = branch.cleanup ? `; cleanup=${branch.cleanup.status}${cleanupOid}` : ''
|
|
653
1082
|
lines.push(`- \`${branch.name}\`: ${branch.state}${cleanup}${suffix}`)
|
|
654
1083
|
}
|
|
655
1084
|
for (const result of results) {
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
: ''
|
|
663
|
-
const cleanup = result.cleanup ? `; cleanup=${result.cleanup.status}` : ''
|
|
1085
|
+
let suffix = ''
|
|
1086
|
+
if (result.url) suffix = ` — ${result.url}`
|
|
1087
|
+
else if (result.error) suffix = ` — ${result.error}`
|
|
1088
|
+
else if (result.rationale) suffix = ` — ${result.rationale}`
|
|
1089
|
+
const cleanupOid = result.oid ? `; oid=${result.oid}` : ''
|
|
1090
|
+
const cleanup = result.cleanup ? `; cleanup=${result.cleanup.status}${cleanupOid}` : ''
|
|
664
1091
|
lines.push(`- \`${result.source}\`: ${result.status}${cleanup}${suffix}`)
|
|
665
1092
|
}
|
|
666
1093
|
for (const warning of inventory.warnings) lines.push(`- ⚠️ ${warning}`)
|
|
@@ -668,109 +1095,309 @@ export function formatReport({ inventory, results }) {
|
|
|
668
1095
|
}
|
|
669
1096
|
|
|
670
1097
|
/**
|
|
671
|
-
*
|
|
672
|
-
*
|
|
673
|
-
* @param {
|
|
674
|
-
* @returns {
|
|
1098
|
+
* Повертає fail-closed keep-рішення для всього batch.
|
|
1099
|
+
* @param {Array<object>} batch candidates
|
|
1100
|
+
* @param {string} error validation/runner error
|
|
1101
|
+
* @returns {Array<object>} keep decisions
|
|
675
1102
|
*/
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
const createPr = deps.createPullRequest ?? createPullRequest
|
|
686
|
-
const cleanup = deps.cleanupSource ?? cleanupSource
|
|
687
|
-
const inventory = inventoryFn(rootCwd, { spawnFn })
|
|
688
|
-
const candidates = [...inventory.branches, ...inventory.stashes].filter(item => item.state === 'review')
|
|
689
|
-
const decisions = []
|
|
1103
|
+
function failedTriageDecisions(batch, error) {
|
|
1104
|
+
return batch.map(candidate => ({
|
|
1105
|
+
source: candidate.source,
|
|
1106
|
+
action: 'keep',
|
|
1107
|
+
rationale: `LLM triage failed: ${error}`,
|
|
1108
|
+
incomplete: true,
|
|
1109
|
+
groups: []
|
|
1110
|
+
}))
|
|
1111
|
+
}
|
|
690
1112
|
|
|
1113
|
+
/**
|
|
1114
|
+
* Виконує min→validation→max triage по bounded batches.
|
|
1115
|
+
* @param {object} args orchestration context
|
|
1116
|
+
* @returns {Promise<Array<object>>} decisions
|
|
1117
|
+
*/
|
|
1118
|
+
async function triageCandidates(args) {
|
|
1119
|
+
const { candidates, runner, task, rootCwd, deps, log, progress } = args
|
|
1120
|
+
const decisions = []
|
|
691
1121
|
for (let offset = 0; offset < candidates.length; offset += REVIEW_BATCH_SIZE) {
|
|
692
1122
|
const batch = candidates.slice(offset, offset + REVIEW_BATCH_SIZE)
|
|
693
|
-
|
|
694
|
-
const
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
const decision = bySource.get(candidate.source)
|
|
711
|
-
decisions.push(
|
|
712
|
-
decision && ['pr', 'keep', 'drop'].includes(decision.action)
|
|
713
|
-
? decision
|
|
714
|
-
: { source: candidate.source, action: 'keep', rationale: 'Невалідна LLM-відповідь', groups: [] }
|
|
715
|
-
)
|
|
1123
|
+
const key = `triage-${offset}`
|
|
1124
|
+
const detail = `${offset + 1}-${offset + batch.length}/${candidates.length}`
|
|
1125
|
+
progress.step(key, detail)
|
|
1126
|
+
let outcome
|
|
1127
|
+
try {
|
|
1128
|
+
outcome = await callWithValidatedFallback({
|
|
1129
|
+
runner,
|
|
1130
|
+
prompt: buildTriagePrompt(batch, task),
|
|
1131
|
+
cwd: rootCwd,
|
|
1132
|
+
deps,
|
|
1133
|
+
log,
|
|
1134
|
+
label: `triage ${detail}`,
|
|
1135
|
+
onAttempt: ({ tier }) => progress.step(key, detail, tier),
|
|
1136
|
+
validate: result => validateTriageOutcome(result, batch)
|
|
1137
|
+
})
|
|
1138
|
+
} finally {
|
|
1139
|
+
progress.done(key)
|
|
716
1140
|
}
|
|
1141
|
+
if (outcome.ok) decisions.push(...outcome.validation.value.decisions)
|
|
1142
|
+
else decisions.push(...failedTriageDecisions(batch, outcome.error))
|
|
717
1143
|
}
|
|
1144
|
+
return decisions
|
|
1145
|
+
}
|
|
718
1146
|
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
1147
|
+
/**
|
|
1148
|
+
* Матеріалізує одне triage-рішення у report results.
|
|
1149
|
+
* @param {object} args orchestration context
|
|
1150
|
+
* @returns {Promise<Array<object>>} results одного source
|
|
1151
|
+
*/
|
|
1152
|
+
async function materializeDecision(args) {
|
|
1153
|
+
const { decision, candidate, runner, rootCwd, deps, spawnFn, log, createPr, progress, prState } = args
|
|
1154
|
+
if (decision.action !== 'pr') {
|
|
1155
|
+
return [
|
|
1156
|
+
{
|
|
726
1157
|
source: decision.source,
|
|
727
1158
|
status: decision.action === 'drop' ? 'drop-recommended' : 'kept',
|
|
728
|
-
rationale: decision.rationale ?? ''
|
|
1159
|
+
rationale: decision.rationale ?? '',
|
|
1160
|
+
...(decision.incomplete === true && { incomplete: true }),
|
|
1161
|
+
...(candidate.oid && { oid: candidate.oid })
|
|
1162
|
+
}
|
|
1163
|
+
]
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const results = []
|
|
1167
|
+
for (const group of decision.groups) {
|
|
1168
|
+
prState.index += 1
|
|
1169
|
+
const key = `pr-${prState.index}`
|
|
1170
|
+
const prefix = `${prState.index}/${prState.total} · ${decision.source}`
|
|
1171
|
+
progress.step(key, `${prefix} · worktree`)
|
|
1172
|
+
try {
|
|
1173
|
+
const result = await createPr({
|
|
1174
|
+
candidate: { ...candidate, rationale: decision.rationale },
|
|
1175
|
+
group,
|
|
1176
|
+
runner,
|
|
1177
|
+
rootCwd,
|
|
1178
|
+
deps,
|
|
1179
|
+
spawnFn,
|
|
1180
|
+
log,
|
|
1181
|
+
onProgress: (step, tier) => progress.step(key, `${prefix} · ${step}`, tier)
|
|
729
1182
|
})
|
|
730
|
-
continue
|
|
731
|
-
}
|
|
732
|
-
const groups = Array.isArray(decision.groups) ? decision.groups : []
|
|
733
|
-
if (groups.length === 0) {
|
|
734
1183
|
results.push({
|
|
735
1184
|
source: decision.source,
|
|
736
|
-
|
|
737
|
-
|
|
1185
|
+
...(candidate.oid && { oid: candidate.oid }),
|
|
1186
|
+
...result
|
|
738
1187
|
})
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
for (const group of groups) {
|
|
742
|
-
results.push(
|
|
743
|
-
await createPr({
|
|
744
|
-
candidate: { ...candidate, rationale: decision.rationale },
|
|
745
|
-
group,
|
|
746
|
-
runner,
|
|
747
|
-
rootCwd,
|
|
748
|
-
deps,
|
|
749
|
-
spawnFn,
|
|
750
|
-
log
|
|
751
|
-
}).then(result => ({ source: decision.source, ...result }))
|
|
752
|
-
)
|
|
1188
|
+
} finally {
|
|
1189
|
+
progress.done(key)
|
|
753
1190
|
}
|
|
754
1191
|
}
|
|
1192
|
+
return results
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Перетворює всі validated decisions у PR/keep/drop results.
|
|
1197
|
+
* @param {object} args orchestration context
|
|
1198
|
+
* @returns {Promise<{bySource:Map<string,object>,results:Array<object>}>} materialized state
|
|
1199
|
+
*/
|
|
1200
|
+
async function materializeDecisions(args) {
|
|
1201
|
+
const { decisions, candidates } = args
|
|
1202
|
+
const bySource = new Map(candidates.map(candidate => [candidate.source, candidate]))
|
|
1203
|
+
const results = []
|
|
1204
|
+
for (const decision of decisions) {
|
|
1205
|
+
const candidate = bySource.get(decision.source)
|
|
1206
|
+
if (!candidate) continue
|
|
1207
|
+
results.push(...(await materializeDecision({ ...args, decision, candidate })))
|
|
1208
|
+
}
|
|
1209
|
+
return { bySource, results }
|
|
1210
|
+
}
|
|
755
1211
|
|
|
1212
|
+
/**
|
|
1213
|
+
* Прибирає Git-доведені merged/patch-equivalent branches.
|
|
1214
|
+
* @param {object} args cleanup context
|
|
1215
|
+
*/
|
|
1216
|
+
function cleanupInactiveBranches(args) {
|
|
1217
|
+
const { inventory, cleanup, rootCwd, spawnFn, progress, cleanupState } = args
|
|
756
1218
|
for (const branch of inventory.branches) {
|
|
757
|
-
|
|
758
|
-
|
|
1219
|
+
const inactive = ['merged', 'patch-equivalent'].includes(branch.state)
|
|
1220
|
+
if (inactive && !branch.worktree && !branch.pr) {
|
|
1221
|
+
cleanupState.index += 1
|
|
1222
|
+
const key = `cleanup-${cleanupState.index}`
|
|
1223
|
+
progress.step(key, branch.source)
|
|
1224
|
+
try {
|
|
1225
|
+
branch.cleanup = cleanup(branch, rootCwd, spawnFn)
|
|
1226
|
+
} finally {
|
|
1227
|
+
progress.done(key)
|
|
1228
|
+
}
|
|
759
1229
|
}
|
|
760
1230
|
}
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/**
|
|
1234
|
+
* Прибирає source лише після drop або успіху всіх його PR groups.
|
|
1235
|
+
* @param {object} args cleanup context
|
|
1236
|
+
*/
|
|
1237
|
+
function cleanupMaterializedSources(args) {
|
|
1238
|
+
const { bySource, results, cleanup, rootCwd, spawnFn, progress, cleanupState } = args
|
|
761
1239
|
for (const [source, candidate] of bySource) {
|
|
762
1240
|
const sourceResults = results.filter(result => result.source === source)
|
|
763
|
-
const
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
1241
|
+
const dropped = sourceResults.some(result => result.status === 'drop-recommended')
|
|
1242
|
+
const allPrCreated = sourceResults.length > 0 && sourceResults.every(result => result.status === 'pr-created')
|
|
1243
|
+
if (!dropped && !allPrCreated) continue
|
|
1244
|
+
cleanupState.index += 1
|
|
1245
|
+
const key = `cleanup-${cleanupState.index}`
|
|
1246
|
+
progress.step(key, source)
|
|
1247
|
+
try {
|
|
767
1248
|
const cleanupResult = cleanup(candidate, rootCwd, spawnFn)
|
|
768
1249
|
for (const result of sourceResults) result.cleanup = cleanupResult
|
|
1250
|
+
} finally {
|
|
1251
|
+
progress.done(key)
|
|
769
1252
|
}
|
|
770
1253
|
}
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/**
|
|
1257
|
+
* Рахує точний cleanup total після завершення PR-фази.
|
|
1258
|
+
* @param {object} inventory Git inventory
|
|
1259
|
+
* @param {Map<string,object>} bySource review sources
|
|
1260
|
+
* @param {Array<object>} results результати PR-фази
|
|
1261
|
+
* @returns {number} кількість sources
|
|
1262
|
+
*/
|
|
1263
|
+
function countCleanupSources(inventory, bySource, results) {
|
|
1264
|
+
const inactive = inventory.branches.filter(branch => {
|
|
1265
|
+
return ['merged', 'patch-equivalent'].includes(branch.state) && !branch.worktree && !branch.pr
|
|
1266
|
+
}).length
|
|
1267
|
+
const reviewed = bySource
|
|
1268
|
+
.keys()
|
|
1269
|
+
.filter(source => {
|
|
1270
|
+
const sourceResults = results.filter(result => result.source === source)
|
|
1271
|
+
const dropped = sourceResults.some(result => result.status === 'drop-recommended')
|
|
1272
|
+
const allPrCreated = sourceResults.length > 0 && sourceResults.every(result => result.status === 'pr-created')
|
|
1273
|
+
return dropped || allPrCreated
|
|
1274
|
+
})
|
|
1275
|
+
.toArray().length
|
|
1276
|
+
return inactive + reviewed
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* JS-оркестратор: inventory → bounded LLM triage → deterministic PR pipeline.
|
|
1281
|
+
* @param {{cwd?:string,runner?:'pi'|'cursor'|'codex',task?:string,log?:(line:string)=>void,isTTY?:boolean,deps?:object}} [options] опції
|
|
1282
|
+
* @returns {Promise<{ok:boolean,report:string,inventory:object,results:Array<object>}>} результат
|
|
1283
|
+
*/
|
|
1284
|
+
export async function runGitReconcileOrchestrator(options = {}) {
|
|
1285
|
+
const rootCwd = options.cwd ?? process.cwd()
|
|
1286
|
+
const runner = options.runner ?? 'pi'
|
|
1287
|
+
const task = options.task ?? ''
|
|
1288
|
+
const log = options.log ?? (line => console.log(line))
|
|
1289
|
+
const deps = options.deps ?? {}
|
|
1290
|
+
const spawnFn = deps.spawnFn ?? spawnSync
|
|
1291
|
+
const inventoryFn = deps.inventoryRepository ?? inventoryRepository
|
|
1292
|
+
const createPr = deps.createPullRequest ?? createPullRequest
|
|
1293
|
+
const cleanup = deps.cleanupSource ?? cleanupSource
|
|
1294
|
+
const reporterFactory = deps.createProgressReporter ?? createProgressReporter
|
|
1295
|
+
const isTTY = options.isTTY ?? process.stdout.isTTY === true
|
|
1296
|
+
const now = deps.now ?? (() => performance.now())
|
|
1297
|
+
|
|
1298
|
+
const inventoryStartedAt = now()
|
|
1299
|
+
log('⏳ 1/4 inventory')
|
|
1300
|
+
const inventory = inventoryFn(rootCwd, { spawnFn })
|
|
1301
|
+
const candidates = [...inventory.branches, ...inventory.stashes].filter(item => item.state === 'review')
|
|
1302
|
+
log(
|
|
1303
|
+
`✅ 1/4 inventory · ${elapsedLabel(inventoryStartedAt, now)} · ${inventory.branches.length} branches · ${inventory.stashes.length} stash`
|
|
1304
|
+
)
|
|
1305
|
+
|
|
1306
|
+
const triageTotal = Math.ceil(candidates.length / REVIEW_BATCH_SIZE)
|
|
1307
|
+
const triageProgress = createPhaseProgress({
|
|
1308
|
+
total: triageTotal,
|
|
1309
|
+
unitLabel: 'triage-пакетів',
|
|
1310
|
+
phase: '2/4 triage',
|
|
1311
|
+
log,
|
|
1312
|
+
isTTY,
|
|
1313
|
+
reporterFactory
|
|
1314
|
+
})
|
|
1315
|
+
const triageStartedAt = now()
|
|
1316
|
+
let decisions
|
|
1317
|
+
try {
|
|
1318
|
+
decisions = await triageCandidates({
|
|
1319
|
+
candidates,
|
|
1320
|
+
runner,
|
|
1321
|
+
task,
|
|
1322
|
+
rootCwd,
|
|
1323
|
+
deps,
|
|
1324
|
+
log,
|
|
1325
|
+
progress: triageProgress
|
|
1326
|
+
})
|
|
1327
|
+
} finally {
|
|
1328
|
+
triageProgress.stop()
|
|
1329
|
+
}
|
|
1330
|
+
log(`✅ 2/4 triage · ${elapsedLabel(triageStartedAt, now)} · ${triageTotal} batches`)
|
|
1331
|
+
|
|
1332
|
+
const prTotal = decisions.reduce((total, decision) => {
|
|
1333
|
+
return total + (decision.action === 'pr' && Array.isArray(decision.groups) ? decision.groups.length : 0)
|
|
1334
|
+
}, 0)
|
|
1335
|
+
const prProgress = createPhaseProgress({
|
|
1336
|
+
total: prTotal,
|
|
1337
|
+
unitLabel: 'PR-груп',
|
|
1338
|
+
phase: '3/4 PR',
|
|
1339
|
+
log,
|
|
1340
|
+
isTTY,
|
|
1341
|
+
reporterFactory
|
|
1342
|
+
})
|
|
1343
|
+
const prStartedAt = now()
|
|
1344
|
+
const prState = { index: 0, total: prTotal }
|
|
1345
|
+
let materialized
|
|
1346
|
+
try {
|
|
1347
|
+
materialized = await materializeDecisions({
|
|
1348
|
+
decisions,
|
|
1349
|
+
candidates,
|
|
1350
|
+
runner,
|
|
1351
|
+
rootCwd,
|
|
1352
|
+
deps,
|
|
1353
|
+
spawnFn,
|
|
1354
|
+
log,
|
|
1355
|
+
createPr,
|
|
1356
|
+
progress: prProgress,
|
|
1357
|
+
prState
|
|
1358
|
+
})
|
|
1359
|
+
} finally {
|
|
1360
|
+
prProgress.stop()
|
|
1361
|
+
}
|
|
1362
|
+
log(`✅ 3/4 PR · ${elapsedLabel(prStartedAt, now)} · ${prTotal} groups`)
|
|
1363
|
+
|
|
1364
|
+
const { bySource, results } = materialized
|
|
1365
|
+
const cleanupCount = countCleanupSources(inventory, bySource, results)
|
|
1366
|
+
const cleanupProgress = createPhaseProgress({
|
|
1367
|
+
total: cleanupCount,
|
|
1368
|
+
unitLabel: 'джерел',
|
|
1369
|
+
phase: '4/4 cleanup',
|
|
1370
|
+
log,
|
|
1371
|
+
isTTY,
|
|
1372
|
+
reporterFactory
|
|
1373
|
+
})
|
|
1374
|
+
const cleanupStartedAt = now()
|
|
1375
|
+
const cleanupState = { index: 0 }
|
|
1376
|
+
try {
|
|
1377
|
+
cleanupInactiveBranches({
|
|
1378
|
+
inventory,
|
|
1379
|
+
cleanup,
|
|
1380
|
+
rootCwd,
|
|
1381
|
+
spawnFn,
|
|
1382
|
+
progress: cleanupProgress,
|
|
1383
|
+
cleanupState
|
|
1384
|
+
})
|
|
1385
|
+
cleanupMaterializedSources({
|
|
1386
|
+
bySource,
|
|
1387
|
+
results,
|
|
1388
|
+
cleanup,
|
|
1389
|
+
rootCwd,
|
|
1390
|
+
spawnFn,
|
|
1391
|
+
progress: cleanupProgress,
|
|
1392
|
+
cleanupState
|
|
1393
|
+
})
|
|
1394
|
+
} finally {
|
|
1395
|
+
cleanupProgress.stop()
|
|
1396
|
+
}
|
|
1397
|
+
log(`✅ 4/4 cleanup · ${elapsedLabel(cleanupStartedAt, now)} · ${cleanupCount} sources`)
|
|
771
1398
|
|
|
772
1399
|
const report = formatReport({ inventory, results })
|
|
773
1400
|
log(report)
|
|
774
|
-
const ok = results.every(result => result.status !== 'failed')
|
|
1401
|
+
const ok = results.every(result => result.status !== 'failed' && result.incomplete !== true)
|
|
775
1402
|
return { ok, report, inventory, results }
|
|
776
1403
|
}
|