@7n/rules 1.48.2 → 1.49.1
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 +29 -0
- package/bin/n-rules-cli.mjs +5 -8
- package/package.json +1 -1
- package/rules/changelog/.changes/260724-1500.md +5 -0
- package/rules/doc-files/docgen-files-batch/docs/index.md +9 -0
- package/rules/doc-files/docgen-files-batch/docs/main.md +59 -18
- package/rules/doc-files/docgen-files-batch/main.mjs +280 -29
- package/rules/doc-files/docgen-gen/docs/index.md +9 -0
- package/rules/doc-files/docgen-gen/docs/main.md +40 -29
- package/rules/doc-files/docgen-gen/main.mjs +79 -25
- package/rules/test/coverage/fix-worker.mjs +9 -1
- package/rules/test/coverage/lib/classify/verdict-schema.mjs +3 -1
- package/scripts/docs/skills-cli.md +18 -24
- package/scripts/lib/acp-runner.mjs +1 -1
- package/scripts/lib/lint-surface/collateral-veto.mjs +78 -2
- package/scripts/lib/lint-surface/docs/collateral-veto.md +30 -16
- package/scripts/lib/lint-surface/docs/index.md +1 -0
- package/scripts/lib/lint-surface/docs/run-fix.md +8 -23
- package/scripts/lib/lint-surface/docs/snapshot.md +6 -17
- package/scripts/lib/lint-surface/docs/test-gate.md +29 -0
- package/scripts/lib/lint-surface/run-fix.mjs +209 -38
- package/scripts/lib/lint-surface/snapshot.mjs +6 -0
- package/scripts/lib/lint-surface/test-gate.mjs +87 -0
- package/scripts/skills-cli.mjs +60 -10
- package/scripts/utils/docs/glob-compat.md +20 -14
- package/scripts/utils/glob-compat.mjs +18 -3
- package/skills/git-reconcile/SKILL.md +58 -0
- package/skills/git-reconcile/js/docs/index.md +9 -0
- package/skills/git-reconcile/js/docs/orchestrate.md +33 -0
- package/skills/git-reconcile/js/orchestrate.mjs +776 -0
- package/skills/git-reconcile/main.json +1 -0
- package/skills/taze/js/docs/orchestrate.md +40 -18
- package/skills/taze/js/orchestrate.mjs +6 -3
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
/** @see ./docs/orchestrate.md */
|
|
2
|
+
import { spawnSync } from 'node:child_process'
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const BASE_REF = 'origin/main'
|
|
7
|
+
const REVIEW_BATCH_SIZE = 10
|
|
8
|
+
const PROMPT_TEXT_LIMIT = 12_000
|
|
9
|
+
const SOURCE_BRANCH_PREFIX = 'branch:'
|
|
10
|
+
const SOURCE_STASH_PREFIX = 'stash:'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Виконує команду без shell-інтерполяції.
|
|
14
|
+
* @param {string} command виконуваний файл
|
|
15
|
+
* @param {string[]} args аргументи
|
|
16
|
+
* @param {string} cwd робочий каталог
|
|
17
|
+
* @param {typeof spawnSync} spawnFn інжект для тестів
|
|
18
|
+
* @param {{ allowFailure?: boolean, input?: string }} [options] режим помилки та stdin
|
|
19
|
+
* @returns {{ status: number, stdout: string, stderr: string }} результат
|
|
20
|
+
*/
|
|
21
|
+
function run(command, args, cwd, spawnFn, options = {}) {
|
|
22
|
+
const result = spawnFn(command, args, {
|
|
23
|
+
cwd,
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
input: options.input,
|
|
26
|
+
env: { ...process.env, GIT_EDITOR: 'true' },
|
|
27
|
+
maxBuffer: 16 * 1024 * 1024
|
|
28
|
+
})
|
|
29
|
+
const normalized = {
|
|
30
|
+
status: result.status ?? 1,
|
|
31
|
+
stdout: result.stdout ?? '',
|
|
32
|
+
stderr: result.stderr ?? ''
|
|
33
|
+
}
|
|
34
|
+
if (!options.allowFailure && normalized.status !== 0) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`${command} ${args.join(' ')} → exit ${normalized.status}: ${normalized.stderr || normalized.stdout}`
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
return normalized
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Запускає git у конкретному checkout.
|
|
44
|
+
* @param {string[]} args аргументи git
|
|
45
|
+
* @param {string} cwd checkout
|
|
46
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
47
|
+
* @param {{ allowFailure?: boolean, input?: string }} [options] режим
|
|
48
|
+
* @returns {{ status: number, stdout: string, stderr: string }} результат
|
|
49
|
+
*/
|
|
50
|
+
function git(args, cwd, spawnFn, options) {
|
|
51
|
+
return run('git', args, cwd, spawnFn, options)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Розбирає JSON без падіння оркестратора.
|
|
56
|
+
* @param {string} text JSON-текст
|
|
57
|
+
* @param {unknown} fallback fallback
|
|
58
|
+
* @returns {unknown} розібране значення або fallback
|
|
59
|
+
*/
|
|
60
|
+
function parseJson(text, fallback) {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(text)
|
|
63
|
+
} catch {
|
|
64
|
+
return fallback
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Парсить `git worktree list --porcelain` у branch→path.
|
|
70
|
+
* @param {string} text porcelain
|
|
71
|
+
* @returns {Map<string, string>} повний ref гілки → checkout
|
|
72
|
+
*/
|
|
73
|
+
export function parseWorktrees(text) {
|
|
74
|
+
const out = new Map()
|
|
75
|
+
let path = ''
|
|
76
|
+
for (const line of text.split('\n')) {
|
|
77
|
+
if (line.startsWith('worktree ')) path = line.slice('worktree '.length)
|
|
78
|
+
if (line.startsWith('branch ')) out.set(line.slice('branch '.length), path)
|
|
79
|
+
if (line.length === 0) path = ''
|
|
80
|
+
}
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Нормалізує branch ref у назву для зіставлення з GitHub PR.
|
|
86
|
+
* @param {string} ref повний ref
|
|
87
|
+
* @returns {string} коротке ім'я
|
|
88
|
+
*/
|
|
89
|
+
function branchName(ref) {
|
|
90
|
+
return ref.replace(/^refs\/heads\//, '').replace(/^refs\/remotes\/origin\//, '')
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Дедуплікує local/remote refs одного commit: remote має пріоритет, але
|
|
95
|
+
* worktree-protection локального ref переноситься у запис.
|
|
96
|
+
* @param {Array<{ref:string, oid:string, date:string}>} refs сирі refs
|
|
97
|
+
* @param {Map<string,string>} worktrees branch→path
|
|
98
|
+
* @returns {Array<{ref:string, oid:string, date:string, worktree:string|null,aliases:string[]}>} refs
|
|
99
|
+
*/
|
|
100
|
+
export function dedupeRefs(refs, worktrees) {
|
|
101
|
+
const byOid = new Map()
|
|
102
|
+
for (const item of refs) {
|
|
103
|
+
if (item.ref === 'refs/remotes/origin/HEAD' || branchName(item.ref) === 'main') continue
|
|
104
|
+
const existing = byOid.get(item.oid)
|
|
105
|
+
const worktree = worktrees.get(item.ref) ?? existing?.worktree ?? null
|
|
106
|
+
const isRemote = item.ref.startsWith('refs/remotes/origin/')
|
|
107
|
+
const aliases = [...new Set([...(existing?.aliases ?? []), item.ref])].toSorted()
|
|
108
|
+
if (!existing || isRemote) {
|
|
109
|
+
byOid.set(item.oid, { ...item, worktree, aliases })
|
|
110
|
+
} else if (worktree) {
|
|
111
|
+
existing.worktree = worktree
|
|
112
|
+
existing.aliases = aliases
|
|
113
|
+
} else {
|
|
114
|
+
existing.aliases = aliases
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return [...byOid.values()].toSorted((a, b) => a.ref.localeCompare(b.ref))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Збирає відкриті PR; недоступний gh не блокує git-inventory.
|
|
122
|
+
* @param {string} cwd корінь
|
|
123
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
124
|
+
* @returns {Map<string,{number:number,url:string}>} head branch → PR
|
|
125
|
+
*/
|
|
126
|
+
function openPullRequests(cwd, spawnFn) {
|
|
127
|
+
const result = run(
|
|
128
|
+
'gh',
|
|
129
|
+
['pr', 'list', '--state', 'open', '--limit', '500', '--json', 'headRefName,number,url'],
|
|
130
|
+
cwd,
|
|
131
|
+
spawnFn,
|
|
132
|
+
{ allowFailure: true }
|
|
133
|
+
)
|
|
134
|
+
if (result.status !== 0) return new Map()
|
|
135
|
+
const rows = /** @type {Array<{headRefName:string,number:number,url:string}>} */ (parseJson(result.stdout, []))
|
|
136
|
+
return new Map(rows.map(row => [row.headRefName, { number: row.number, url: row.url }]))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Збирає compact commit metadata лише для patch-унікальних non-merge комітів.
|
|
141
|
+
* @param {string[]} oids commit ids
|
|
142
|
+
* @param {string} cwd корінь
|
|
143
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
144
|
+
* @returns {Array<{oid:string,subject:string}>} коміти
|
|
145
|
+
*/
|
|
146
|
+
function commitMetadata(oids, cwd, spawnFn) {
|
|
147
|
+
return oids.map(oid => {
|
|
148
|
+
const subject = git(['show', '-s', '--format=%s', oid], cwd, spawnFn).stdout.trim()
|
|
149
|
+
return { oid, subject }
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Витягає конфліктні файли з `git merge-tree`.
|
|
155
|
+
* @param {string} text merge-tree output
|
|
156
|
+
* @returns {string[]} унікальні шляхи
|
|
157
|
+
*/
|
|
158
|
+
export function conflictFiles(text) {
|
|
159
|
+
const files = new Set()
|
|
160
|
+
for (const line of text.split('\n')) {
|
|
161
|
+
const content = line.match(/^CONFLICT \(.+?\): Merge conflict in (.+)$/)
|
|
162
|
+
const rename = line.match(/^CONFLICT \(rename\/delete\): .+? renamed to (.+?) in .+?, but deleted/)
|
|
163
|
+
const modifyDelete = line.match(/^CONFLICT \(modify\/delete\): (.+?) deleted in /)
|
|
164
|
+
const path = content?.[1] ?? rename?.[1] ?? modifyDelete?.[1]
|
|
165
|
+
if (path) files.add(path)
|
|
166
|
+
}
|
|
167
|
+
return [...files].toSorted()
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Збирає детермінований Git inventory. Нічого не видаляє і не змінює у
|
|
172
|
+
* checkout, крім оновлення remote refs через fetch --prune.
|
|
173
|
+
* @param {string} cwd корінь репо
|
|
174
|
+
* @param {{ spawnFn?: typeof spawnSync }} [deps] інжекти
|
|
175
|
+
* @returns {{base:string,branches:Array<object>,stashes:Array<object>,warnings:string[]}} inventory
|
|
176
|
+
*/
|
|
177
|
+
export function inventoryRepository(cwd, deps = {}) {
|
|
178
|
+
const spawnFn = deps.spawnFn ?? spawnSync
|
|
179
|
+
git(['fetch', '--prune', 'origin'], cwd, spawnFn)
|
|
180
|
+
git(['rev-parse', '--verify', BASE_REF], cwd, spawnFn)
|
|
181
|
+
|
|
182
|
+
const worktrees = parseWorktrees(git(['worktree', 'list', '--porcelain'], cwd, spawnFn).stdout)
|
|
183
|
+
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
|
+
],
|
|
190
|
+
cwd,
|
|
191
|
+
spawnFn
|
|
192
|
+
).stdout
|
|
193
|
+
.split('\n')
|
|
194
|
+
.filter(Boolean)
|
|
195
|
+
const refs = dedupeRefs(
|
|
196
|
+
refLines.map(line => {
|
|
197
|
+
const [ref, oid, date] = line.split('\0')
|
|
198
|
+
return { ref, oid, date }
|
|
199
|
+
}),
|
|
200
|
+
worktrees
|
|
201
|
+
)
|
|
202
|
+
const prs = openPullRequests(cwd, spawnFn)
|
|
203
|
+
const warnings = []
|
|
204
|
+
if (prs.size === 0) warnings.push('GitHub PR inventory порожній або gh недоступний')
|
|
205
|
+
|
|
206
|
+
const branches = refs.map(item => {
|
|
207
|
+
const name = branchName(item.ref)
|
|
208
|
+
const merged = git(['merge-base', '--is-ancestor', item.ref, BASE_REF], cwd, spawnFn, {
|
|
209
|
+
allowFailure: true
|
|
210
|
+
}).status === 0
|
|
211
|
+
const novelOids = merged
|
|
212
|
+
? []
|
|
213
|
+
: git(
|
|
214
|
+
['rev-list', '--right-only', '--cherry-pick', '--no-merges', `${BASE_REF}...${item.ref}`],
|
|
215
|
+
cwd,
|
|
216
|
+
spawnFn
|
|
217
|
+
).stdout
|
|
218
|
+
.split('\n')
|
|
219
|
+
.filter(Boolean)
|
|
220
|
+
.toReversed()
|
|
221
|
+
const counts = git(['rev-list', '--left-right', '--count', `${BASE_REF}...${item.ref}`], cwd, spawnFn)
|
|
222
|
+
.stdout.trim()
|
|
223
|
+
.split(/\s+/)
|
|
224
|
+
.map(Number)
|
|
225
|
+
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'
|
|
235
|
+
const changedFiles =
|
|
236
|
+
state === 'review'
|
|
237
|
+
? git(['diff', '--name-status', `${BASE_REF}...${item.ref}`], cwd, spawnFn).stdout
|
|
238
|
+
.split('\n')
|
|
239
|
+
.filter(Boolean)
|
|
240
|
+
.slice(0, 200)
|
|
241
|
+
: []
|
|
242
|
+
const mergeTree =
|
|
243
|
+
state === 'review'
|
|
244
|
+
? git(['merge-tree', BASE_REF, item.ref], cwd, spawnFn, { allowFailure: true }).stdout
|
|
245
|
+
: ''
|
|
246
|
+
return {
|
|
247
|
+
source: `${SOURCE_BRANCH_PREFIX}${item.ref}`,
|
|
248
|
+
ref: item.ref,
|
|
249
|
+
name,
|
|
250
|
+
oid: item.oid,
|
|
251
|
+
date: item.date,
|
|
252
|
+
state,
|
|
253
|
+
worktree: item.worktree,
|
|
254
|
+
pr,
|
|
255
|
+
behind: counts[0] ?? 0,
|
|
256
|
+
ahead: counts[1] ?? 0,
|
|
257
|
+
commits: commitMetadata(novelOids, cwd, spawnFn),
|
|
258
|
+
changedFiles,
|
|
259
|
+
conflicts: conflictFiles(mergeTree)
|
|
260
|
+
}
|
|
261
|
+
})
|
|
262
|
+
|
|
263
|
+
const stashRows = git(['stash', 'list', '--format=%gd%x00%H%x00%gs'], cwd, spawnFn).stdout
|
|
264
|
+
.split('\n')
|
|
265
|
+
.filter(Boolean)
|
|
266
|
+
const stashes = stashRows.map(line => {
|
|
267
|
+
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)
|
|
271
|
+
return {
|
|
272
|
+
source: `${SOURCE_STASH_PREFIX}${ref}`,
|
|
273
|
+
ref,
|
|
274
|
+
oid,
|
|
275
|
+
subject,
|
|
276
|
+
state: 'review',
|
|
277
|
+
changedFiles
|
|
278
|
+
}
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
return { base: BASE_REF, branches, stashes, warnings }
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Формує bounded semantic-triage prompt. Git-факти вже пораховані JS; модель
|
|
286
|
+
* не виконує shell-команди й повертає лише JSON-рішення.
|
|
287
|
+
* @param {Array<object>} candidates review branches/stashes
|
|
288
|
+
* @param {string} task додатковий намір користувача
|
|
289
|
+
* @returns {string} промпт
|
|
290
|
+
*/
|
|
291
|
+
export function buildTriagePrompt(candidates, task = '') {
|
|
292
|
+
return [
|
|
293
|
+
'Ти виконуєш лише semantic triage уже зібраних Git-фактів.',
|
|
294
|
+
'Не запускай команди, не редагуй файли, не створюй PR і не видаляй refs.',
|
|
295
|
+
task ? `Намір користувача: ${task}` : '',
|
|
296
|
+
'Для кожного source поверни action: pr, keep або drop.',
|
|
297
|
+
'pr — лише завершена корисна поведінка; groups розділяють незалежні PR.',
|
|
298
|
+
'keep — бракує доказів або робота активна/незавершена. drop — явно артефакт/застаріле.',
|
|
299
|
+
'Для branch group commits — непорожній subset commit oid із facts. Для stash commits не потрібні.',
|
|
300
|
+
'Відповідь — лише JSON без markdown:',
|
|
301
|
+
'{"decisions":[{"source":"branch:refs/remotes/origin/x","action":"pr","rationale":"...","groups":[{"title":"fix: ...","commits":["oid"]}]}]}',
|
|
302
|
+
JSON.stringify(candidates)
|
|
303
|
+
]
|
|
304
|
+
.filter(Boolean)
|
|
305
|
+
.join('\n\n')
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Витягає JSON object із чистої або fenced відповіді.
|
|
310
|
+
* @param {string} text відповідь моделі
|
|
311
|
+
* @returns {object|null} object або null
|
|
312
|
+
*/
|
|
313
|
+
export function parseDecisionEnvelope(text) {
|
|
314
|
+
const trimmed = text.trim()
|
|
315
|
+
const unfenced = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '')
|
|
316
|
+
const first = unfenced.indexOf('{')
|
|
317
|
+
const last = unfenced.lastIndexOf('}')
|
|
318
|
+
if (first === -1 || last <= first) return null
|
|
319
|
+
const parsed = parseJson(unfenced.slice(first, last + 1), null)
|
|
320
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Викликає вибраний LLM runner для одного bounded-завдання.
|
|
325
|
+
* @param {'pi'|'cursor'|'codex'} runner раннер
|
|
326
|
+
* @param {string} prompt промпт
|
|
327
|
+
* @param {string} cwd робочий каталог
|
|
328
|
+
* @param {object} deps інжекти
|
|
329
|
+
* @returns {Promise<{ok:boolean,text:string,error:string|null}>} результат
|
|
330
|
+
*/
|
|
331
|
+
export async function callRunner(runner, prompt, cwd, deps = {}) {
|
|
332
|
+
if (runner === 'pi') {
|
|
333
|
+
let runAgentSkill = deps.runAgentSkill
|
|
334
|
+
if (!runAgentSkill) {
|
|
335
|
+
const module = await import('@7n/llm-lib/agent-skill')
|
|
336
|
+
runAgentSkill = module.runAgentSkill
|
|
337
|
+
}
|
|
338
|
+
let text = ''
|
|
339
|
+
const result = await runAgentSkill(prompt, {
|
|
340
|
+
skillId: 'git-reconcile',
|
|
341
|
+
tier: 'max',
|
|
342
|
+
cwd,
|
|
343
|
+
deps: { out: chunk => (text += chunk) }
|
|
344
|
+
})
|
|
345
|
+
return { ok: result.ok, text, error: result.error }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
let runAcpAgent = deps.runAcpAgent
|
|
349
|
+
if (!runAcpAgent) {
|
|
350
|
+
const module = await import('@7n/llm-lib/acp')
|
|
351
|
+
runAcpAgent = module.runAcpAgent
|
|
352
|
+
}
|
|
353
|
+
try {
|
|
354
|
+
const text = await runAcpAgent(runner, prompt, cwd, { tier: 'max' })
|
|
355
|
+
return { ok: true, text, error: null }
|
|
356
|
+
} catch (error) {
|
|
357
|
+
return { ok: false, text: '', error: error instanceof Error ? error.message : String(error) }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Перетворює довільний title/ref на branch slug.
|
|
363
|
+
* @param {string} value title/ref
|
|
364
|
+
* @returns {string} slug
|
|
365
|
+
*/
|
|
366
|
+
export function branchSlug(value) {
|
|
367
|
+
const slug = value
|
|
368
|
+
.toLowerCase()
|
|
369
|
+
.replaceAll(/[^a-z0-9]+/g, '-')
|
|
370
|
+
.replaceAll(/^-|-$/g, '')
|
|
371
|
+
.slice(0, 40)
|
|
372
|
+
return slug || 'change'
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Обирає вільну rescue-гілку.
|
|
377
|
+
* @param {string} title PR title
|
|
378
|
+
* @param {string} cwd корінь
|
|
379
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
380
|
+
* @returns {string} branch
|
|
381
|
+
*/
|
|
382
|
+
function chooseBranch(title, cwd, spawnFn) {
|
|
383
|
+
const base = `codex/reconcile-${branchSlug(title)}`
|
|
384
|
+
let candidate = base
|
|
385
|
+
let suffix = 2
|
|
386
|
+
while (
|
|
387
|
+
git(['show-ref', '--verify', '--quiet', `refs/heads/${candidate}`], cwd, spawnFn, {
|
|
388
|
+
allowFailure: true
|
|
389
|
+
}).status === 0 ||
|
|
390
|
+
git(['ls-remote', '--exit-code', '--heads', 'origin', candidate], cwd, spawnFn, {
|
|
391
|
+
allowFailure: true
|
|
392
|
+
}).status === 0
|
|
393
|
+
) {
|
|
394
|
+
candidate = `${base}-${suffix}`
|
|
395
|
+
suffix += 1
|
|
396
|
+
}
|
|
397
|
+
return candidate
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* Створює керований worktree та детерміновано пересаджує його branch на
|
|
402
|
+
* origin/main без зміни вихідного checkout.
|
|
403
|
+
* @param {string} title PR title
|
|
404
|
+
* @param {string} source source id
|
|
405
|
+
* @param {string} cwd корінь
|
|
406
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
407
|
+
* @returns {{branch:string,cwd:string}} worktree
|
|
408
|
+
*/
|
|
409
|
+
function createReconcileWorktree(title, source, cwd, spawnFn) {
|
|
410
|
+
const branch = chooseBranch(title, cwd, spawnFn)
|
|
411
|
+
run('npx', ['@7n/mt', 'worktree', 'create', branch, `git-reconcile: ${source}`], cwd, spawnFn)
|
|
412
|
+
const worktreeCwd = join(cwd, '.worktrees', branch.replaceAll('/', '-'))
|
|
413
|
+
git(['switch', '--detach', BASE_REF], worktreeCwd, spawnFn)
|
|
414
|
+
git(['branch', '-f', branch, BASE_REF], worktreeCwd, spawnFn)
|
|
415
|
+
git(['switch', branch], worktreeCwd, spawnFn)
|
|
416
|
+
return { branch, cwd: worktreeCwd }
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Перевіряє, чи лишились unmerged paths.
|
|
421
|
+
* @param {string} cwd worktree
|
|
422
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
423
|
+
* @returns {string[]} шляхи
|
|
424
|
+
*/
|
|
425
|
+
function unresolvedFiles(cwd, spawnFn) {
|
|
426
|
+
return git(['diff', '--name-only', '--diff-filter=U'], cwd, spawnFn).stdout
|
|
427
|
+
.split('\n')
|
|
428
|
+
.filter(Boolean)
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Просить LLM розв'язати лише вже матеріалізований конфлікт.
|
|
433
|
+
* @param {object} args контекст
|
|
434
|
+
* @returns {Promise<void>}
|
|
435
|
+
*/
|
|
436
|
+
async function resolveConflict({ runner, source, worktreeCwd, deps, spawnFn }) {
|
|
437
|
+
const unresolved = unresolvedFiles(worktreeCwd, spawnFn)
|
|
438
|
+
const prompt = [
|
|
439
|
+
`У worktree ${worktreeCwd} JS уже застосував ${source} до свіжого origin/main.`,
|
|
440
|
+
`Розв'яжи лише змістові конфлікти: ${unresolved.join(', ')}.`,
|
|
441
|
+
'Порівняй current main та намір перенесеної зміни; не використовуй ours/theirs всліпу.',
|
|
442
|
+
'Збережи актуальну поведінку main, перенеси лише відсутню корисну частину.',
|
|
443
|
+
'За потреби онови regression test. Не commit, не push, не створюй PR і не видаляй refs.',
|
|
444
|
+
'Наприкінці прибери conflict markers і коротко переліч перевірки.'
|
|
445
|
+
].join('\n\n')
|
|
446
|
+
const outcome = await callRunner(runner, prompt, worktreeCwd, deps)
|
|
447
|
+
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
|
+
git(['add', '-A'], worktreeCwd, spawnFn)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* Застосовує один branch group або stash у worktree.
|
|
455
|
+
* @param {object} args контекст
|
|
456
|
+
* @returns {Promise<void>}
|
|
457
|
+
*/
|
|
458
|
+
async function applySource({ source, sourceOid, commits, runner, rootCwd, worktreeCwd, deps, spawnFn }) {
|
|
459
|
+
if (source.startsWith(SOURCE_BRANCH_PREFIX)) {
|
|
460
|
+
for (const oid of commits) {
|
|
461
|
+
const result = git(['cherry-pick', oid], worktreeCwd, spawnFn, { allowFailure: true })
|
|
462
|
+
if (result.status === 0) continue
|
|
463
|
+
if (unresolvedFiles(worktreeCwd, spawnFn).length === 0) {
|
|
464
|
+
throw new Error(`cherry-pick ${oid}: ${result.stderr || result.stdout}`)
|
|
465
|
+
}
|
|
466
|
+
await resolveConflict({ runner, source: oid, worktreeCwd, deps, spawnFn })
|
|
467
|
+
if (
|
|
468
|
+
git(['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'], worktreeCwd, spawnFn, {
|
|
469
|
+
allowFailure: true
|
|
470
|
+
}).status === 0
|
|
471
|
+
) {
|
|
472
|
+
git(['cherry-pick', '--continue'], worktreeCwd, spawnFn)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const stashRef = source.slice(SOURCE_STASH_PREFIX.length)
|
|
479
|
+
const patch = git(['stash', 'show', '-p', '--binary', sourceOid ?? stashRef], rootCwd, spawnFn).stdout
|
|
480
|
+
const applied = git(['apply', '--3way', '-'], worktreeCwd, spawnFn, {
|
|
481
|
+
allowFailure: true,
|
|
482
|
+
input: patch
|
|
483
|
+
})
|
|
484
|
+
if (applied.status !== 0) {
|
|
485
|
+
if (unresolvedFiles(worktreeCwd, spawnFn).length === 0) {
|
|
486
|
+
throw new Error(`git apply ${stashRef}: ${applied.stderr || applied.stdout}`)
|
|
487
|
+
}
|
|
488
|
+
await resolveConflict({ runner, source: stashRef, worktreeCwd, deps, spawnFn })
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Делегує LLM лише behavioral verification/fix у вже зібраному worktree.
|
|
494
|
+
* @param {object} args контекст
|
|
495
|
+
* @returns {Promise<string>} текст відповіді
|
|
496
|
+
*/
|
|
497
|
+
async function finalizeBehavior({ runner, source, rationale, worktreeCwd, deps }) {
|
|
498
|
+
const prompt = [
|
|
499
|
+
`JS переніс ${source} на свіжий origin/main у ${worktreeCwd}.`,
|
|
500
|
+
`Очікувана користь: ${rationale}`,
|
|
501
|
+
'Перевір реальний diff і call sites. Доведи лише перенесену поведінку до готовності:',
|
|
502
|
+
'- додай/онови regression test, якщо це bug fix;',
|
|
503
|
+
'- виконай найвужчі релевантні тести;',
|
|
504
|
+
'- виконай repository-required docs/change checks;',
|
|
505
|
+
'- не роби unrelated refactor або formatting churn.',
|
|
506
|
+
'Не commit, не push, не створюй PR і не видаляй refs. Якщо поведінку неможливо безпечно підтвердити — нічого не маскуй, поверни чіткий blocker.'
|
|
507
|
+
].join('\n\n')
|
|
508
|
+
const outcome = await callRunner(runner, prompt, worktreeCwd, deps)
|
|
509
|
+
if (!outcome.ok) throw new Error(`LLM behavioral verification: ${outcome.error}`)
|
|
510
|
+
return outcome.text.slice(0, PROMPT_TEXT_LIMIT)
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Створює один готовий PR. При будь-якому провалі worktree лишається для
|
|
515
|
+
* ручного відновлення; прибирається тільки після успішного gh pr create.
|
|
516
|
+
* @param {object} args параметри
|
|
517
|
+
* @returns {Promise<{status:string,url?:string,branch?:string,error?:string,worktree?:string}>} результат
|
|
518
|
+
*/
|
|
519
|
+
async function createPullRequest({ candidate, group, runner, rootCwd, deps, spawnFn, log }) {
|
|
520
|
+
const source = candidate.source
|
|
521
|
+
const validOids = new Set(candidate.commits?.map(commit => commit.oid) ?? [])
|
|
522
|
+
const commits = source.startsWith(SOURCE_BRANCH_PREFIX)
|
|
523
|
+
? (group.commits ?? []).filter(oid => validOids.has(oid))
|
|
524
|
+
: []
|
|
525
|
+
if (source.startsWith(SOURCE_BRANCH_PREFIX) && commits.length === 0) {
|
|
526
|
+
return { status: 'kept', error: 'LLM не вибрала жодного валідного commit oid' }
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const worktree = createReconcileWorktree(group.title, source, rootCwd, spawnFn)
|
|
530
|
+
log(`🌿 ${source} → ${worktree.branch}`)
|
|
531
|
+
try {
|
|
532
|
+
await applySource({
|
|
533
|
+
source,
|
|
534
|
+
sourceOid: candidate.oid,
|
|
535
|
+
commits,
|
|
536
|
+
runner,
|
|
537
|
+
rootCwd,
|
|
538
|
+
worktreeCwd: worktree.cwd,
|
|
539
|
+
deps,
|
|
540
|
+
spawnFn
|
|
541
|
+
})
|
|
542
|
+
const verification = await finalizeBehavior({
|
|
543
|
+
runner,
|
|
544
|
+
source,
|
|
545
|
+
rationale: group.rationale ?? candidate.rationale ?? '',
|
|
546
|
+
worktreeCwd: worktree.cwd,
|
|
547
|
+
deps
|
|
548
|
+
})
|
|
549
|
+
const unresolved = unresolvedFiles(worktree.cwd, spawnFn)
|
|
550
|
+
if (unresolved.length > 0) throw new Error(`Нерозв'язані конфлікти: ${unresolved.join(', ')}`)
|
|
551
|
+
git(['diff', '--check'], worktree.cwd, spawnFn)
|
|
552
|
+
git(['add', '-A'], worktree.cwd, spawnFn)
|
|
553
|
+
const staged = git(['diff', '--cached', '--quiet'], worktree.cwd, spawnFn, {
|
|
554
|
+
allowFailure: true
|
|
555
|
+
}).status !== 0
|
|
556
|
+
if (staged) git(['commit', '-m', group.title], worktree.cwd, spawnFn)
|
|
557
|
+
const ahead = Number(git(['rev-list', '--count', `${BASE_REF}..HEAD`], worktree.cwd, spawnFn).stdout.trim())
|
|
558
|
+
if (!ahead) throw new Error('Після reconciliation немає змін відносно origin/main')
|
|
559
|
+
|
|
560
|
+
const changelog = run(
|
|
561
|
+
'npx',
|
|
562
|
+
['@7n/rules', 'lint', 'changelog', '--no-fix'],
|
|
563
|
+
worktree.cwd,
|
|
564
|
+
spawnFn,
|
|
565
|
+
{ allowFailure: true }
|
|
566
|
+
)
|
|
567
|
+
if (changelog.status !== 0) {
|
|
568
|
+
throw new Error(`changelog gate: ${changelog.stderr || changelog.stdout}`)
|
|
569
|
+
}
|
|
570
|
+
git(['diff', '--check', `${BASE_REF}...HEAD`], worktree.cwd, spawnFn)
|
|
571
|
+
git(['push', '-u', 'origin', worktree.branch], worktree.cwd, spawnFn)
|
|
572
|
+
|
|
573
|
+
const body = [
|
|
574
|
+
`Джерело: \`${source}\`.`,
|
|
575
|
+
'',
|
|
576
|
+
group.rationale ?? candidate.rationale ?? 'Корисну поведінку перенесено на актуальний main.',
|
|
577
|
+
'',
|
|
578
|
+
'Перевірки:',
|
|
579
|
+
'- `git diff --check origin/main...HEAD`',
|
|
580
|
+
'- `npx @7n/rules lint changelog --no-fix`',
|
|
581
|
+
verification ? `- LLM behavioral verification: ${verification.slice(0, 1000)}` : ''
|
|
582
|
+
]
|
|
583
|
+
.filter(Boolean)
|
|
584
|
+
.join('\n')
|
|
585
|
+
const pr = run(
|
|
586
|
+
'gh',
|
|
587
|
+
['pr', 'create', '--base', 'main', '--head', worktree.branch, '--title', group.title, '--body', body],
|
|
588
|
+
worktree.cwd,
|
|
589
|
+
spawnFn
|
|
590
|
+
).stdout.trim()
|
|
591
|
+
run('npx', ['@7n/mt', 'worktree', 'remove', worktree.branch], rootCwd, spawnFn, {
|
|
592
|
+
allowFailure: true
|
|
593
|
+
})
|
|
594
|
+
return { status: 'pr-created', url: pr, branch: worktree.branch }
|
|
595
|
+
} catch (error) {
|
|
596
|
+
return {
|
|
597
|
+
status: 'failed',
|
|
598
|
+
error: error instanceof Error ? error.message : String(error),
|
|
599
|
+
branch: worktree.branch,
|
|
600
|
+
worktree: worktree.cwd
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Видаляє точний source після Git-доказу неактуальності або успішного
|
|
607
|
+
* перенесення. Protected/open-PR refs не потрапляють у цей крок.
|
|
608
|
+
* @param {object} candidate inventory source
|
|
609
|
+
* @param {string} rootCwd корінь репо
|
|
610
|
+
* @param {typeof spawnSync} spawnFn інжект
|
|
611
|
+
* @returns {{status:string,error?:string}} cleanup result
|
|
612
|
+
*/
|
|
613
|
+
export function cleanupSource(candidate, rootCwd, spawnFn = spawnSync) {
|
|
614
|
+
try {
|
|
615
|
+
if (candidate.source.startsWith(SOURCE_STASH_PREFIX)) {
|
|
616
|
+
const rows = git(['stash', 'list', '--format=%gd%x00%H'], rootCwd, spawnFn).stdout
|
|
617
|
+
.split('\n')
|
|
618
|
+
.filter(Boolean)
|
|
619
|
+
.map(line => line.split('\0'))
|
|
620
|
+
const row = rows.find(([, oid]) => oid === candidate.oid)
|
|
621
|
+
if (!row) return { status: 'already-removed' }
|
|
622
|
+
git(['stash', 'drop', row[0]], rootCwd, spawnFn)
|
|
623
|
+
return { status: 'removed' }
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
for (const ref of candidate.aliases ?? [candidate.ref]) {
|
|
627
|
+
if (ref.startsWith('refs/remotes/origin/')) {
|
|
628
|
+
git(['push', 'origin', '--delete', branchName(ref)], rootCwd, spawnFn)
|
|
629
|
+
} else if (ref.startsWith('refs/heads/')) {
|
|
630
|
+
git(['branch', '-D', branchName(ref)], rootCwd, spawnFn)
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return { status: 'removed' }
|
|
634
|
+
} catch (error) {
|
|
635
|
+
return {
|
|
636
|
+
status: 'cleanup-failed',
|
|
637
|
+
error: error instanceof Error ? error.message : String(error)
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Формує deterministic report.
|
|
644
|
+
* @param {{inventory:object,results:Array<object>}} args дані
|
|
645
|
+
* @returns {string} markdown
|
|
646
|
+
*/
|
|
647
|
+
export function formatReport({ inventory, results }) {
|
|
648
|
+
const lines = ['## git-reconcile: підсумок']
|
|
649
|
+
for (const branch of inventory.branches) {
|
|
650
|
+
if (branch.state === 'review') continue
|
|
651
|
+
const suffix = branch.pr?.url ? ` — ${branch.pr.url}` : branch.worktree ? ` — ${branch.worktree}` : ''
|
|
652
|
+
const cleanup = branch.cleanup ? `; cleanup=${branch.cleanup.status}` : ''
|
|
653
|
+
lines.push(`- \`${branch.name}\`: ${branch.state}${cleanup}${suffix}`)
|
|
654
|
+
}
|
|
655
|
+
for (const result of results) {
|
|
656
|
+
const suffix = result.url
|
|
657
|
+
? ` — ${result.url}`
|
|
658
|
+
: result.error
|
|
659
|
+
? ` — ${result.error}`
|
|
660
|
+
: result.rationale
|
|
661
|
+
? ` — ${result.rationale}`
|
|
662
|
+
: ''
|
|
663
|
+
const cleanup = result.cleanup ? `; cleanup=${result.cleanup.status}` : ''
|
|
664
|
+
lines.push(`- \`${result.source}\`: ${result.status}${cleanup}${suffix}`)
|
|
665
|
+
}
|
|
666
|
+
for (const warning of inventory.warnings) lines.push(`- ⚠️ ${warning}`)
|
|
667
|
+
return lines.join('\n')
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* JS-оркестратор: inventory → bounded LLM triage → deterministic PR pipeline.
|
|
672
|
+
* Нічого не видаляє; `drop` є лише рекомендацією у звіті.
|
|
673
|
+
* @param {{cwd?:string,runner?:'pi'|'cursor'|'codex',task?:string,log?:(line:string)=>void,deps?:object}} [options] опції
|
|
674
|
+
* @returns {Promise<{ok:boolean,report:string,inventory:object,results:Array<object>}>} результат
|
|
675
|
+
*/
|
|
676
|
+
export async function runGitReconcileOrchestrator(options = {}) {
|
|
677
|
+
const rootCwd = options.cwd ?? process.cwd()
|
|
678
|
+
const runner = options.runner ?? 'pi'
|
|
679
|
+
const task = options.task ?? ''
|
|
680
|
+
const log = options.log ?? (line => console.log(line))
|
|
681
|
+
const deps = options.deps ?? {}
|
|
682
|
+
const spawnFn = deps.spawnFn ?? spawnSync
|
|
683
|
+
const call = deps.callRunner ?? callRunner
|
|
684
|
+
const inventoryFn = deps.inventoryRepository ?? inventoryRepository
|
|
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 = []
|
|
690
|
+
|
|
691
|
+
for (let offset = 0; offset < candidates.length; offset += REVIEW_BATCH_SIZE) {
|
|
692
|
+
const batch = candidates.slice(offset, offset + REVIEW_BATCH_SIZE)
|
|
693
|
+
log(`🧠 semantic triage ${offset + 1}-${offset + batch.length}/${candidates.length}`)
|
|
694
|
+
const outcome = await call(runner, buildTriagePrompt(batch, task), rootCwd, deps)
|
|
695
|
+
if (!outcome.ok) {
|
|
696
|
+
for (const candidate of batch) {
|
|
697
|
+
decisions.push({
|
|
698
|
+
source: candidate.source,
|
|
699
|
+
action: 'keep',
|
|
700
|
+
rationale: `LLM triage failed: ${outcome.error}`,
|
|
701
|
+
groups: []
|
|
702
|
+
})
|
|
703
|
+
}
|
|
704
|
+
continue
|
|
705
|
+
}
|
|
706
|
+
const envelope = parseDecisionEnvelope(outcome.text)
|
|
707
|
+
const returned = Array.isArray(envelope?.decisions) ? envelope.decisions : []
|
|
708
|
+
const bySource = new Map(returned.map(decision => [decision.source, decision]))
|
|
709
|
+
for (const candidate of batch) {
|
|
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
|
+
)
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const bySource = new Map(candidates.map(candidate => [candidate.source, candidate]))
|
|
720
|
+
const results = []
|
|
721
|
+
for (const decision of decisions) {
|
|
722
|
+
const candidate = bySource.get(decision.source)
|
|
723
|
+
if (!candidate) continue
|
|
724
|
+
if (decision.action !== 'pr') {
|
|
725
|
+
results.push({
|
|
726
|
+
source: decision.source,
|
|
727
|
+
status: decision.action === 'drop' ? 'drop-recommended' : 'kept',
|
|
728
|
+
rationale: decision.rationale ?? ''
|
|
729
|
+
})
|
|
730
|
+
continue
|
|
731
|
+
}
|
|
732
|
+
const groups = Array.isArray(decision.groups) ? decision.groups : []
|
|
733
|
+
if (groups.length === 0) {
|
|
734
|
+
results.push({
|
|
735
|
+
source: decision.source,
|
|
736
|
+
status: 'kept',
|
|
737
|
+
error: 'LLM позначила pr без groups'
|
|
738
|
+
})
|
|
739
|
+
continue
|
|
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
|
+
)
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
for (const branch of inventory.branches) {
|
|
757
|
+
if (['merged', 'patch-equivalent'].includes(branch.state) && !branch.worktree && !branch.pr) {
|
|
758
|
+
branch.cleanup = cleanup(branch, rootCwd, spawnFn)
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
for (const [source, candidate] of bySource) {
|
|
762
|
+
const sourceResults = results.filter(result => result.source === source)
|
|
763
|
+
const shouldRemove =
|
|
764
|
+
sourceResults.some(result => result.status === 'drop-recommended') ||
|
|
765
|
+
(sourceResults.length > 0 && sourceResults.every(result => result.status === 'pr-created'))
|
|
766
|
+
if (shouldRemove) {
|
|
767
|
+
const cleanupResult = cleanup(candidate, rootCwd, spawnFn)
|
|
768
|
+
for (const result of sourceResults) result.cleanup = cleanupResult
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const report = formatReport({ inventory, results })
|
|
773
|
+
log(report)
|
|
774
|
+
const ok = results.every(result => result.status !== 'failed')
|
|
775
|
+
return { ok, report, inventory, results }
|
|
776
|
+
}
|