@7n/rules 1.14.1 → 1.15.0

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.
@@ -1,15 +1,16 @@
1
1
  /** @see ./docs/orchestrate.md */
2
2
  import { spawnSync } from 'node:child_process'
3
3
  import { existsSync } from 'node:fs'
4
- import { copyFile, readFile, rm } from 'node:fs/promises'
4
+ import { copyFile, rm } from 'node:fs/promises'
5
5
  import { join } from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
6
7
 
7
- import { parse as parseToml } from 'smol-toml'
8
-
8
+ import { assertEcosystemProvider } from '../../../scripts/lib/plugin-api.mjs'
9
+ import { readNRulesConfigLite } from '../../../scripts/lib/read-n-rules-config-lite.mjs'
10
+ import { getHandlers, resolvePlugins } from '../../../scripts/lib/resolve-plugins.mjs'
9
11
  import { getMonorepoPackageRootDirs } from '../../../scripts/lib/workspaces.mjs'
10
- import { collectCargoDiff } from './cargo-diff.mjs'
11
12
  import { collectTazeDiff } from './diff.mjs'
12
- import { collectUvDiff, listDirectDependencies } from './uv-diff.mjs'
13
+ import rustProvider from './rust-provider.mjs'
13
14
 
14
15
  /** Суфікс бекапу package.json — той самий, що й у `diff.mjs`/кроці 1 SKILL.md. */
15
16
  const BACKUP_SUFFIX = '.taze-bak'
@@ -38,54 +39,6 @@ export function buildDependencyPrompt({ workspace, pkg, from, to }) {
38
39
  ].join('\n')
39
40
  }
40
41
 
41
- /**
42
- * Промпт ОДНОГО ітеративного виклику для Rust-крейта — Rust-варіант
43
- * [`buildDependencyPrompt`] (кроки 4-6 SKILL.md, Rust-гілка), для ОДНОГО
44
- * major-крейта. Кроки 1-3/7/8 виконує оркестратор детерміновано, без LLM.
45
- * @param {{manifest: string, pkg: string, from: string, to: string}} entry запис major-diff (з `collectCargoDiff`)
46
- * @returns {string} готовий промпт
47
- */
48
- export function buildCargoDependencyPrompt({ manifest, pkg, from, to }) {
49
- return [
50
- '# Major-оновлення одного Rust-крейта: перевірка сумісності й рефакторинг',
51
- '',
52
- `Крейт \`${pkg}\` у \`${manifest}\`: **${from} → ${to}** — вже застосовано в Cargo.toml/Cargo.lock (кроки 1-3 виконано детерміновано, без тебе). Твоя задача — лише breaking-changes-перевірка й, за потреби, рефакторинг.`,
53
- '',
54
- '## Кроки',
55
- `1. Зібрати breaking changes цього оновлення: адреса репозиторію з поля \`repository\`/\`documentation\` крейта на crates.io (https://crates.io/crates/${pkg}) — CHANGELOG.md репозиторію чи GitHub Releases. Якщо немає — різниця по публічному API (\`pub fn\`/\`pub struct\`/\`pub trait\`) між закешованою старою версією (\`~/.cargo/registry/src/*/${pkg}-<стара-версія>/\`) і новою.`,
56
- `2. Знайти використання зачепленого API в коді проєкту (\`rg -n --type rust\` по use-шляхах/викликах \`${pkg}\`).`,
57
- '3. Сумісно — нічого не робити. Несумісно — застосувати міграцію (перейменувати use-шлях, оновити сигнатуру виклику, замінити видалений макрос еквівалентом).',
58
- '4. Якщо були правки — запусти `cargo fmt --all -- --check`, `cargo clippy --all-targets --all-features -- -D warnings`, `cargo test`.',
59
- '5. Нетривіальна/неоднозначна міграція — не вгадуй, залиш TODO-коментар із посиланням на CHANGELOG.',
60
- '',
61
- 'У відповіді одним абзацом підсумуй: сумісно / зрефакторено (які файли) / TODO (чому).'
62
- ].join('\n')
63
- }
64
-
65
- /**
66
- * Промпт ОДНОГО ітеративного виклику для Python-пакета — Python/uv-варіант
67
- * [`buildDependencyPrompt`] (кроки 4-6 SKILL.md, Python-гілка), для ОДНОГО
68
- * major-пакета. Кроки 1-3/7/8 виконує оркестратор детерміновано, без LLM.
69
- * @param {{manifest: string, pkg: string, from: string, to: string}} entry запис major-diff (з `collectUvDiff`)
70
- * @returns {string} готовий промпт
71
- */
72
- export function buildUvDependencyPrompt({ manifest, pkg, from, to }) {
73
- return [
74
- '# Major-оновлення одного Python-пакета: перевірка сумісності й рефакторинг',
75
- '',
76
- `Пакет \`${pkg}\` у \`${manifest}\`: **${from} → ${to}** — вже застосовано (\`uv remove\` + \`uv add --bounds lower\` виконано детерміновано, без тебе). Твоя задача — лише breaking-changes-перевірка й, за потреби, рефакторинг.`,
77
- '',
78
- '## Кроки',
79
- `1. Зібрати breaking changes цього оновлення: CHANGELOG/Releases репозиторію пакета (адреса — зі сторінки https://pypi.org/project/${pkg}/) між ${from} і ${to}.`,
80
- `2. Знайти використання зачепленого API в коді проєкту (\`rg -n --type py\` по імпортах/викликах \`${pkg}\`).`,
81
- '3. Сумісно — нічого не робити. Несумісно — застосувати міграцію (перейменувати імпорт, оновити сигнатуру виклику, замінити видалений параметр еквівалентом).',
82
- '4. Якщо були правки — запусти наявні в проєкті лінт/typecheck/test (`ruff`/`mypy`/`pytest` тощо — залежно від того, що реально налаштовано).',
83
- '5. Нетривіальна/неоднозначна міграція — не вгадуй, залиш TODO-коментар із посиланням на CHANGELOG.',
84
- '',
85
- 'У відповіді одним абзацом підсумуй: сумісно / зрефакторено (які файли) / TODO (чому).'
86
- ].join('\n')
87
- }
88
-
89
42
  /**
90
43
  * Диспетчер одного ітеративного виклику на обраний раннер. `pi` — вбудований
91
44
  * pi-агент (`@7n/llm-lib/agent-skill`; текст перехоплюється через `deps.out`,
@@ -154,7 +107,7 @@ function assertRunningInWorktree(cwd, spawnFn) {
154
107
  }
155
108
 
156
109
  /**
157
- * Синхронно виконує детерміновану команду (bunx/bun/find), кидає з
110
+ * Синхронно виконує детерміновану команду (bunx/bun), кидає з
158
111
  * exit-кодом+stderr при провалі.
159
112
  * @param {string} cmd бінарник
160
113
  * @param {string[]} args аргументи
@@ -206,174 +159,94 @@ export async function cleanupBackups(cwd, workspaces, deps = {}) {
206
159
  }
207
160
 
208
161
  /**
209
- * Чи встановлений cargo-edit (дає `cargo upgrade`) — без нього неможливо
210
- * детерміновано перетнути major-межу Rust-залежностей (голий `cargo update`
211
- * піднімає лише semver-сумісні версії; SKILL.md §Передумови).
212
- * @param {typeof spawnSync} spawnFn інжект для тестів
213
- * @returns {boolean} true `cargo upgrade` доступна
214
- */
215
- function hasCargoEdit(spawnFn) {
216
- return spawnFn('cargo', ['upgrade', '--version'], { encoding: 'utf8' }).status === 0
217
- }
218
-
219
- /**
220
- * Бекапить кожен Cargo.toml + спільний кореневий Cargo.lock (крок 1 SKILL.md,
221
- * Rust-гілка). v1: один спільний workspace/Cargo.lock у корені `cwd` —
222
- * поточна реальна топологія репо (кореневий `Cargo.toml` з `[workspace]` +
223
- * члени); кілька незалежних Cargo-workspace під час не підтримується.
162
+ * Завантажує EcosystemProvider-и з активних плагінів проєкту: `.n-rules.json`
163
+ * (або автодетект за файловими сигналами `pyproject.toml` → lang-python) →
164
+ * `resolvePlugins` (плагін доставляється автоматично при першому запуску) → handler-модулі
165
+ * extension-point `taze` default-експорт кожного валідується
166
+ * `assertEcosystemProvider`. Битий плагін (нема модуля/невалідна форма)
167
+ * warning і пропуск, не провал прогону.
224
168
  * @param {string} cwd корінь репо
225
- * @param {string[]} manifestPaths відносні шляхи Cargo.toml (з `findCargoManifests`)
226
- * @param {{ copyFile?: (src: string, dest: string) => Promise<void> }} [deps] інжект
227
- * @returns {Promise<void>}
169
+ * @param {(line: string) => void} log колбек прогресу
170
+ * @param {{ readNRulesConfigLite?: (cwd: string) => Promise<object>, resolvePlugins?: (root: string, config: object, opts?: object) => object[], getHandlers?: (root: string, config: object, point: string) => Array<{pluginName: string, modulePath: string}>, importModule?: (url: string) => Promise<object> }} [deps] інжекти для тестів
171
+ * @returns {Promise<object[]>} валідні провайдери плагінів
228
172
  */
229
- export async function backupCargoManifests(cwd, manifestPaths, deps = {}) {
230
- const copy = deps.copyFile ?? copyFile
231
- for (const manifest of manifestPaths) {
232
- const manifestPath = join(cwd, manifest)
233
- if (existsSync(manifestPath)) await copy(manifestPath, `${manifestPath}${BACKUP_SUFFIX}`)
173
+ export async function loadPluginTazeProviders(cwd, log, deps = {}) {
174
+ const readConfig = deps.readNRulesConfigLite ?? readNRulesConfigLite
175
+ const config = await readConfig(cwd)
176
+ const resolve = deps.resolvePlugins ?? resolvePlugins
177
+ resolve(cwd, config)
178
+ const handlers = (deps.getHandlers ?? getHandlers)(cwd, config, 'taze')
179
+ // eslint-disable-next-line no-unsanitized/method
180
+ const importModule = deps.importModule ?? (url => import(url))
181
+
182
+ const providers = []
183
+ for (const handler of handlers) {
184
+ try {
185
+ const mod = await importModule(pathToFileURL(handler.modulePath).href)
186
+ providers.push(assertEcosystemProvider(mod.default, handler.pluginName))
187
+ } catch (error) {
188
+ log(`⚠️ Плагін ${handler.pluginName}: taze-провайдер не завантажився — ${error.message}`)
189
+ }
234
190
  }
235
- const lockPath = join(cwd, 'Cargo.lock')
236
- if (existsSync(lockPath)) await copy(lockPath, `${lockPath}${BACKUP_SUFFIX}`)
191
+ return providers
237
192
  }
238
193
 
239
194
  /**
240
- * Прибирає бекапи Cargo.toml/Cargo.lock після завершення (крок 7 SKILL.md,
241
- * Rust-гілка).
242
- * @param {string} cwd корінь репо
243
- * @param {string[]} manifestPaths відносні шляхи Cargo.toml (з `findCargoManifests`)
244
- * @param {{ rm?: (path: string, opts?: object) => Promise<void> }} [deps] інжект
245
- * @returns {Promise<void>}
195
+ * Проганяє одну екосистему (провайдера) наскрізь: detect available →
196
+ * backup → bump → diff → ізольований виклик раннера по кожному major-запису →
197
+ * cleanup. Виняток усередині (bump/diff/команда) не зупиняє інших провайдерів —
198
+ * фіксується в `error` запису екосистеми.
199
+ * @param {import('../../../scripts/lib/plugin-api.mjs').EcosystemProvider} provider провайдер екосистеми
200
+ * @param {{ cwd: string, runner: string, log: (line: string) => void, deps: object, spawnFn: typeof spawnSync, call: typeof callRunner }} ctx контекст прогону
201
+ * @returns {Promise<object>} запис екосистеми для звіту
246
202
  */
247
- export async function cleanupCargoBackups(cwd, manifestPaths, deps = {}) {
248
- const remove = deps.rm ?? rm
249
- for (const manifest of manifestPaths) {
250
- await remove(join(cwd, `${manifest}${BACKUP_SUFFIX}`), { force: true })
203
+ async function runEcosystem(provider, { cwd, runner, log, deps, spawnFn, call }) {
204
+ const base = {
205
+ id: provider.id,
206
+ title: provider.title,
207
+ manifestNoun: provider.manifestNoun,
208
+ skillSection: provider.skillSection
251
209
  }
252
- await remove(join(cwd, `Cargo.lock${BACKUP_SUFFIX}`), { force: true })
253
- }
254
-
255
- /**
256
- * Знаходить Cargo.toml поза node_modules/.worktrees/target (крок 0.2 SKILL.md).
257
- * @param {string} cwd корінь репо
258
- * @param {{ spawnFn?: typeof spawnSync }} [deps] інжект
259
- * @returns {string[]} відносні шляхи знайдених Cargo.toml
260
- */
261
- export function findCargoManifests(cwd, deps = {}) {
262
- const spawnFn = deps.spawnFn ?? spawnSync
263
- const result = spawnFn(
264
- 'find',
265
- [
266
- '.',
267
- '-name',
268
- 'Cargo.toml',
269
- '-not',
270
- '-path',
271
- '*/node_modules/*',
272
- '-not',
273
- '-path',
274
- '*/.worktrees/*',
275
- '-not',
276
- '-path',
277
- '*/target/*'
278
- ],
279
- { cwd, encoding: 'utf8' }
280
- )
281
- return (result.stdout ?? '')
282
- .split('\n')
283
- .map(line => line.trim())
284
- .filter(Boolean)
285
- }
286
-
287
- /**
288
- * Чи встановлений `uv` — без нього неможливо детерміновано перетнути
289
- * major-межу Python-залежностей (SKILL.md §Передумови, Python-гілка).
290
- * @param {typeof spawnSync} spawnFn інжект для тестів
291
- * @returns {boolean} true — `uv` доступний
292
- */
293
- function hasUv(spawnFn) {
294
- return spawnFn('uv', ['--version'], { encoding: 'utf8' }).status === 0
295
- }
210
+ const eco = { ...base, manifests: [], processed: false, skippedReason: null, error: null, minorPatch: 0, results: [] }
211
+ try {
212
+ eco.manifests = provider.detect(cwd, { spawnFn })
213
+ if (eco.manifests.length === 0) return eco
214
+
215
+ const availability = provider.available(spawnFn)
216
+ if (!availability.ok) {
217
+ eco.skippedReason = availability.reason
218
+ log(`⏭ ${provider.title}: ${availability.reason}`)
219
+ return eco
220
+ }
296
221
 
297
- /**
298
- * Знаходить кореневий `pyproject.toml` (крок 0.2 SKILL.md, Python-гілка).
299
- * v1: один кореневий файл, не per-package, як `find`-обхід для Cargo.toml —
300
- * поточна uv-конвенція (single-project, без workspace-обходу).
301
- * @param {string} cwd корінь репо
302
- * @returns {string[]} `['pyproject.toml']`, якщо файл існує, інакше `[]`
303
- */
304
- export function findPyprojectManifest(cwd) {
305
- return existsSync(join(cwd, 'pyproject.toml')) ? ['pyproject.toml'] : []
306
- }
222
+ log(`📦 Бекап (${provider.manifestNoun})...`)
223
+ await provider.backup(cwd, eco.manifests, deps)
224
+ await provider.bump(cwd, eco.manifests, { spawnFn, log, deps })
307
225
 
308
- /**
309
- * Бекапить pyproject.toml + uv.lock (крок 1 SKILL.md, Python-гілка)
310
- * потрібно для класифікації major/minor через `collectUvDiff` після bump-у.
311
- * @param {string} cwd корінь репо
312
- * @param {{ copyFile?: (src: string, dest: string) => Promise<void> }} [deps] інжект
313
- * @returns {Promise<void>}
314
- */
315
- export async function backupUvManifest(cwd, deps = {}) {
316
- const copy = deps.copyFile ?? copyFile
317
- const pyprojectPath = join(cwd, 'pyproject.toml')
318
- if (existsSync(pyprojectPath)) await copy(pyprojectPath, `${pyprojectPath}${BACKUP_SUFFIX}`)
319
- const lockPath = join(cwd, 'uv.lock')
320
- if (existsSync(lockPath)) await copy(lockPath, `${lockPath}${BACKUP_SUFFIX}`)
321
- }
322
-
323
- /**
324
- * Прибирає бекапи pyproject.toml/uv.lock після завершення (крок 7 SKILL.md,
325
- * Python-гілка).
326
- * @param {string} cwd корінь репо
327
- * @param {{ rm?: (path: string, opts?: object) => Promise<void> }} [deps] інжект
328
- * @returns {Promise<void>}
329
- */
330
- export async function cleanupUvBackups(cwd, deps = {}) {
331
- const remove = deps.rm ?? rm
332
- await remove(join(cwd, `pyproject.toml${BACKUP_SUFFIX}`), { force: true })
333
- await remove(join(cwd, `uv.lock${BACKUP_SUFFIX}`), { force: true })
334
- }
226
+ const diff = await provider.diff(cwd, eco.manifests, deps)
227
+ log(`🔍 ${provider.title} diff: ${diff.major.length} major, ${diff.minorPatch} minor/patch`)
335
228
 
336
- /**
337
- * Піднімає кожну пряму залежність pyproject.toml через `uv remove` + `uv add
338
- * <pkg>[extras] --bounds lower` (крок 2 SKILL.md, Python-гілка) — `uv` не
339
- * має єдиної команди "підняти все до latest, навіть через major", на
340
- * відміну від `bunx taze -w -r latest`/`cargo upgrade --incompatible allow`
341
- * (підтверджено емпірично: `uv add <pkg>` на вже присутній залежності —
342
- * no-op, specifier НЕ переписується без попереднього `uv remove`). Провал
343
- * одного пакета (мережа/резолюція) не втрачає прогрес по інших —
344
- * best-effort відновлення оригінального рядка, якщо `uv add` не вдався
345
- * після `uv remove`.
346
- * @param {string} cwd корінь репо
347
- * @param {typeof spawnSync} spawnFn інжект для тестів
348
- * @param {(line: string) => void} log колбек прогресу
349
- * @param {{ readFile?: (path: string, encoding: string) => Promise<string> }} [deps] інжект
350
- * @returns {Promise<void>}
351
- */
352
- async function bumpUvDependencies(cwd, spawnFn, log, deps = {}) {
353
- const read = deps.readFile ?? readFile
354
- const text = await read(join(cwd, 'pyproject.toml'), 'utf8')
355
- const manifest = parseToml(text)
356
- const directDeps = listDirectDependencies(manifest)
357
-
358
- for (const dep of directDeps) {
359
- const pkgSpec = dep.extras.length > 0 ? `${dep.name}[${dep.extras.join(',')}]` : dep.name
360
- const removeResult = spawnFn('uv', ['remove', dep.name], { cwd, encoding: 'utf8' })
361
- if (removeResult.status !== 0) {
362
- log(` ⚠️ uv remove ${dep.name}: ${removeResult.stderr || removeResult.stdout}`)
363
- continue
364
- }
365
- const addResult = spawnFn('uv', ['add', pkgSpec, '--bounds', 'lower'], { cwd, encoding: 'utf8' })
366
- if (addResult.status !== 0) {
367
- log(` ⚠️ uv add ${pkgSpec}: ${addResult.stderr || addResult.stdout} — відновлюю ${dep.raw}`)
368
- spawnFn('uv', ['add', dep.raw], { cwd, encoding: 'utf8' })
229
+ for (const entry of diff.major) {
230
+ log(`🔧 [${provider.id}] ${entry.pkg} (${entry.manifest}): ${entry.from} ${entry.to}...`)
231
+ const outcome = await call(runner, provider.promptFor(entry), cwd, deps)
232
+ eco.results.push({ ...entry, ...outcome })
233
+ log(outcome.ok ? ` ✅ ${entry.pkg}` : ` ❌ ${entry.pkg}: ${outcome.error}`)
369
234
  }
235
+
236
+ await provider.cleanup(cwd, eco.manifests, deps)
237
+ eco.processed = true
238
+ eco.minorPatch = diff.minorPatch
239
+ } catch (error) {
240
+ eco.error = error instanceof Error ? error.message : String(error)
241
+ log(`❌ ${provider.title}: ${eco.error}`)
370
242
  }
243
+ return eco
371
244
  }
372
245
 
373
246
  /**
374
- * Форматує один рядок результату ітерації (спільний для npm- і Rust-гілки).
247
+ * Форматує один рядок результату ітерації (спільний для npm-гілки й екосистем).
375
248
  * @param {{pkg: string, ok: boolean, error: string|null, from: string, to: string}} r результат ітерації
376
- * @param {string} scopeLabel мітка джерела (`workspace` для npm, `manifest` для Rust)
249
+ * @param {string} scopeLabel мітка джерела (`workspace` для npm, `manifest` для екосистем)
377
250
  * @returns {string} один рядок звіту
378
251
  */
379
252
  function formatResultLine(r, scopeLabel) {
@@ -382,24 +255,23 @@ function formatResultLine(r, scopeLabel) {
382
255
  return ` ${status} \`${r.pkg}\` (${scopeLabel}): ${r.from} → ${r.to}${errorSuffix}`
383
256
  }
384
257
 
385
- /** Пуста гілка-екосистема (для дефолту, коли `rust`/`python` не знайдені взагалі). */
386
- const EMPTY_ECOSYSTEM = { manifests: [], processed: false, skippedReason: null, minorPatch: 0, results: [] }
387
-
388
258
  /**
389
- * Додає секцію звіту однієї не-npm екосистемної гілки (Rust/Python) —
390
- * спільна логіка для «Rust-крейти»/«Python-пакети (uv)», обидві мають
391
- * ідентичну форму результату ({manifest, pkg, from, to, ok, error}).
259
+ * Додає секцію звіту однієї екосистеми (Rust/Python/…) — мітки бере з самого
260
+ * запису (`title`/`manifestNoun`/`skillSection` з провайдера).
392
261
  * @param {string[]} lines масив рядків звіту (мутується)
393
- * @param {{manifests:string[], processed:boolean, skippedReason:string|null, minorPatch:number, results:Array<{pkg:string, manifest:string, from:string, to:string, ok:boolean, error:string|null}>}} eco дані гілки
394
- * @param {{title: string, manifestNoun: string, skillSection: string}} labels мітки секції
395
- * @returns {number} totalChanged цієї гілки
262
+ * @param {{title: string, manifestNoun: string, skillSection: string, manifests: string[], processed: boolean, skippedReason: string|null, error: string|null, minorPatch: number, results: Array<{pkg:string, manifest:string, from:string, to:string, ok:boolean, error:string|null}>}} eco запис екосистеми
263
+ * @returns {number} totalChanged цієї екосистеми
396
264
  */
397
- function appendEcosystemSection(lines, eco, { title, manifestNoun, skillSection }) {
398
- if (eco.manifests.length === 0) return 0
399
- lines.push('', `### ${title}`)
265
+ function appendEcosystemSection(lines, eco) {
266
+ if (eco.manifests.length === 0 && !eco.error) return 0
267
+ lines.push('', `### ${eco.title}`)
268
+ if (eco.error) {
269
+ lines.push(`- ❌ Провал (${eco.error}) — переглянь дерево вручну, бекапи могли лишитись`)
270
+ return 0
271
+ }
400
272
  if (!eco.processed) {
401
273
  lines.push(
402
- `- ⏭ Пропущено (${eco.skippedReason}) — ${eco.manifests.length} ${manifestNoun}, онови вручну за ${skillSection}: ${eco.manifests.join(', ')}`
274
+ `- ⏭ Пропущено (${eco.skippedReason}) — ${eco.manifests.length} ${eco.manifestNoun}, онови вручну за ${eco.skillSection}: ${eco.manifests.join(', ')}`
403
275
  )
404
276
  return 0
405
277
  }
@@ -417,24 +289,11 @@ function appendEcosystemSection(lines, eco, { title, manifestNoun, skillSection
417
289
  * minorPatch: number,
418
290
  * totalChanged: number,
419
291
  * results: Array<{pkg:string, workspace:string, from:string, to:string, ok:boolean, error:string|null}>,
420
- * rust?: {
421
- * manifests: string[],
422
- * processed: boolean,
423
- * skippedReason: string|null,
424
- * minorPatch: number,
425
- * results: Array<{pkg:string, manifest:string, from:string, to:string, ok:boolean, error:string|null}>
426
- * },
427
- * python?: {
428
- * manifests: string[],
429
- * processed: boolean,
430
- * skippedReason: string|null,
431
- * minorPatch: number,
432
- * results: Array<{pkg:string, manifest:string, from:string, to:string, ok:boolean, error:string|null}>
433
- * }
434
- * }} args дані звіту
292
+ * ecosystems?: Array<object>
293
+ * }} args дані звіту (`ecosystems` — записи з `runEcosystem`, по одному на провайдера)
435
294
  * @returns {string} markdown-звіт
436
295
  */
437
- export function formatReport({ minorPatch, totalChanged, results, rust = EMPTY_ECOSYSTEM, python = EMPTY_ECOSYSTEM }) {
296
+ export function formatReport({ minorPatch, totalChanged, results, ecosystems = [] }) {
438
297
  const lines = [
439
298
  '## taze: підсумок',
440
299
  '',
@@ -445,18 +304,12 @@ export function formatReport({ minorPatch, totalChanged, results, rust = EMPTY_E
445
304
  lines.push(formatResultLine(r, r.workspace))
446
305
  }
447
306
 
448
- const rustTotalChanged = appendEcosystemSection(lines, rust, {
449
- title: 'Rust-крейти',
450
- manifestNoun: 'Cargo.toml',
451
- skillSection: 'Rust-гілкою SKILL.md'
452
- })
453
- const pythonTotalChanged = appendEcosystemSection(lines, python, {
454
- title: 'Python-пакети (uv)',
455
- manifestNoun: 'pyproject.toml',
456
- skillSection: 'Python-гілкою SKILL.md'
457
- })
458
-
459
- lines.push('', `- **Всього змінено:** ${totalChanged + rustTotalChanged + pythonTotalChanged}`)
307
+ let ecosystemsTotal = 0
308
+ for (const eco of ecosystems) {
309
+ ecosystemsTotal += appendEcosystemSection(lines, eco)
310
+ }
311
+
312
+ lines.push('', `- **Всього змінено:** ${totalChanged + ecosystemsTotal}`)
460
313
  return lines.join('\n')
461
314
  }
462
315
 
@@ -464,16 +317,17 @@ export function formatReport({ minorPatch, totalChanged, results, rust = EMPTY_E
464
317
  * Оркеструє taze: детерміновані кроки (бекап → масовий bump → diff →
465
318
  * прибирання → звіт) без LLM, і по одному ізольованому, обмеженому по
466
319
  * обсягу виклику `callRunner` на кожен major-пакет (кроки 4-6 SKILL.md) —
467
- * замість одного величезного непрозорого ходу на весь монорепо. Кожен
468
- * виклик успадковує власний timeout/idle-timeout раннера, тож падіння
469
- * одного пакета не втрачає прогрес по інших.
320
+ * замість одного величезного непрозорого ходу на весь монорепо. npm/bun-гілка
321
+ * вбудована; решта екосистем EcosystemProvider-и: first-party (Rust/Cargo)
322
+ * плюс завантажені з плагінів (`@7n/rules-lang-*`, extension-point `taze`).
323
+ * Падіння одного пакета/однієї екосистеми не втрачає прогрес по інших.
470
324
  * @param {{
471
325
  * cwd?: string,
472
326
  * runner?: 'pi' | 'cursor' | 'codex',
473
327
  * log?: (line: string) => void,
474
- * deps?: { spawnFn?: typeof spawnSync, collectTazeDiff?: (cwd: string) => Promise<object>, collectCargoDiff?: (cwd: string, manifests: string[]) => Promise<object>, collectUvDiff?: (cwd: string) => Promise<object>, bumpUvDependencies?: (cwd: string, spawnFn: typeof spawnSync, log: (line: string) => void, deps: object) => Promise<void>, callRunner?: (runner: string, prompt: string, cwd: string, deps: object) => Promise<{ok: boolean, text: string, error: string|null}> } & Record<string, unknown>
475
- * }} [options] опції + інжекти для тестів
476
- * @returns {Promise<{ ok: boolean, report: string, results: Array<object>, rustResults: Array<object>, pythonResults: Array<object> }>} результат
328
+ * deps?: { spawnFn?: typeof spawnSync, collectTazeDiff?: (cwd: string) => Promise<object>, callRunner?: (runner: string, prompt: string, cwd: string, deps: object) => Promise<{ok: boolean, text: string, error: string|null}>, ecosystemProviders?: object[] } & Record<string, unknown>
329
+ * }} [options] опції + інжекти для тестів (`deps.ecosystemProviders` повністю замінює список провайдерів)
330
+ * @returns {Promise<{ ok: boolean, report: string, results: Array<object>, ecosystems: Array<object> }>} результат
477
331
  */
478
332
  export async function runTazeOrchestrator(options = {}) {
479
333
  const cwd = options.cwd ?? process.cwd()
@@ -507,90 +361,15 @@ export async function runTazeOrchestrator(options = {}) {
507
361
 
508
362
  await cleanupBackups(cwd, backedUpWorkspaces, deps)
509
363
 
510
- const rustManifests = findCargoManifests(cwd, { spawnFn })
511
- let rust = { manifests: rustManifests, processed: false, skippedReason: null, minorPatch: 0, results: [] }
512
-
513
- if (rustManifests.length > 0 && !hasCargoEdit(spawnFn)) {
514
- rust.skippedReason =
515
- 'cargo-edit не встановлено (`cargo install cargo-edit`) — cargo update без нього недетермінований для major'
516
- log(`⏭ Rust: ${rust.skippedReason}`)
517
- } else if (rustManifests.length > 0) {
518
- log('📦 Бекап Cargo.toml/Cargo.lock...')
519
- await backupCargoManifests(cwd, rustManifests, deps)
520
-
521
- log('⬆️ cargo upgrade --incompatible allow...')
522
- runCommand('cargo', ['upgrade', '--incompatible', 'allow'], cwd, spawnFn)
523
- log('🔄 cargo update...')
524
- runCommand('cargo', ['update'], cwd, spawnFn)
525
-
526
- const collectCargo = deps.collectCargoDiff ?? collectCargoDiff
527
- const cargoDiff = await collectCargo(cwd, rustManifests)
528
- log(`🔍 Rust diff: ${cargoDiff.major.length} major, ${cargoDiff.minorPatch} minor/patch`)
529
-
530
- const rustResults = []
531
- for (const entry of cargoDiff.major) {
532
- log(`🔧 [rust] ${entry.pkg} (${entry.manifest}): ${entry.from} → ${entry.to}...`)
533
- const outcome = await call(runner, buildCargoDependencyPrompt(entry), cwd, deps)
534
- rustResults.push({ ...entry, ...outcome })
535
- log(outcome.ok ? ` ✅ ${entry.pkg}` : ` ❌ ${entry.pkg}: ${outcome.error}`)
536
- }
537
-
538
- await cleanupCargoBackups(cwd, rustManifests, deps)
539
-
540
- rust = {
541
- manifests: rustManifests,
542
- processed: true,
543
- skippedReason: null,
544
- minorPatch: cargoDiff.minorPatch,
545
- results: rustResults
546
- }
547
- }
548
-
549
- const pyprojectManifests = findPyprojectManifest(cwd)
550
- let python = { manifests: pyprojectManifests, processed: false, skippedReason: null, minorPatch: 0, results: [] }
551
-
552
- if (pyprojectManifests.length > 0 && !hasUv(spawnFn)) {
553
- python.skippedReason = '`uv` не встановлено (https://docs.astral.sh/uv/getting-started/installation/)'
554
- log(`⏭ Python: ${python.skippedReason}`)
555
- } else if (pyprojectManifests.length > 0) {
556
- log('📦 Бекап pyproject.toml/uv.lock...')
557
- await backupUvManifest(cwd, deps)
558
-
559
- log('⬆️ uv remove + uv add --bounds lower (по кожній прямій залежності)...')
560
- const bump = deps.bumpUvDependencies ?? bumpUvDependencies
561
- await bump(cwd, spawnFn, log, deps)
562
-
563
- const collectUv = deps.collectUvDiff ?? collectUvDiff
564
- const uvDiff = await collectUv(cwd)
565
- log(`🔍 Python diff: ${uvDiff.major.length} major, ${uvDiff.minorPatch} minor/patch`)
566
-
567
- const pythonResults = []
568
- for (const entry of uvDiff.major) {
569
- log(`🔧 [python] ${entry.pkg} (${entry.manifest}): ${entry.from} → ${entry.to}...`)
570
- const outcome = await call(runner, buildUvDependencyPrompt(entry), cwd, deps)
571
- pythonResults.push({ ...entry, ...outcome })
572
- log(outcome.ok ? ` ✅ ${entry.pkg}` : ` ❌ ${entry.pkg}: ${outcome.error}`)
573
- }
574
-
575
- await cleanupUvBackups(cwd, deps)
576
-
577
- python = {
578
- manifests: pyprojectManifests,
579
- processed: true,
580
- skippedReason: null,
581
- minorPatch: uvDiff.minorPatch,
582
- results: pythonResults
583
- }
364
+ const providers = deps.ecosystemProviders ?? [rustProvider, ...(await loadPluginTazeProviders(cwd, log, deps))]
365
+ const ecosystems = []
366
+ for (const provider of providers) {
367
+ ecosystems.push(await runEcosystem(provider, { cwd, runner, log, deps, spawnFn, call }))
584
368
  }
585
369
 
586
- const report = formatReport({ minorPatch: diff.minorPatch, totalChanged: diff.totalChanged, results, rust, python })
370
+ const report = formatReport({ minorPatch: diff.minorPatch, totalChanged: diff.totalChanged, results, ecosystems })
587
371
  log(report)
588
372
 
589
- return {
590
- ok: results.every(r => r.ok) && rust.results.every(r => r.ok) && python.results.every(r => r.ok),
591
- report,
592
- results,
593
- rustResults: rust.results,
594
- pythonResults: python.results
595
- }
373
+ const ecosystemsOk = ecosystems.every(eco => eco.error === null && eco.results.every(r => r.ok))
374
+ return { ok: results.every(r => r.ok) && ecosystemsOk, report, results, ecosystems }
596
375
  }