@7n/rules 1.14.0 → 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.
Files changed (44) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/package.json +2 -1
  3. package/rules/graphql/tooling/docs/main.md +1 -1
  4. package/rules/graphql/tooling/main.mjs +4 -4
  5. package/rules/js/eslint/docs/main.md +1 -1
  6. package/rules/js/eslint/main.mjs +8 -7
  7. package/rules/js-run/runtime/docs/main.md +1 -1
  8. package/rules/js-run/runtime/main.mjs +3 -3
  9. package/rules/k8s/manifests/main.mjs +6 -6
  10. package/rules/nginx-default-tpl/template/docs/main.md +1 -1
  11. package/rules/nginx-default-tpl/template/main.mjs +5 -5
  12. package/rules/tauri/tooling/docs/main.md +1 -1
  13. package/rules/tauri/tooling/main.mjs +1 -1
  14. package/scripts/lib/docs/ensure-tool.md +24 -22
  15. package/scripts/lib/docs/index.md +4 -1
  16. package/scripts/lib/docs/plugin-api.md +31 -0
  17. package/scripts/lib/docs/resolve-plugins.md +4 -4
  18. package/scripts/lib/docs/run-conftest-batch.md +5 -3
  19. package/scripts/lib/ensure-tool.mjs +125 -31
  20. package/scripts/lib/lint-surface/blocking-inventory.mjs +59 -0
  21. package/scripts/lib/lint-surface/docs/blocking-inventory.md +30 -0
  22. package/scripts/lib/lint-surface/docs/index.md +2 -0
  23. package/scripts/lib/lint-surface/docs/policy-lint-adapter.md +2 -3
  24. package/scripts/lib/lint-surface/docs/run-detectors.md +3 -1
  25. package/scripts/lib/lint-surface/docs/scheduler.md +39 -0
  26. package/scripts/lib/lint-surface/docs/types.md +2 -2
  27. package/scripts/lib/lint-surface/policy-lint-adapter.mjs +4 -3
  28. package/scripts/lib/lint-surface/run-detectors.mjs +142 -31
  29. package/scripts/lib/lint-surface/scheduler.mjs +96 -0
  30. package/scripts/lib/lint-surface/types.mjs +3 -0
  31. package/scripts/lib/plugin-api.mjs +71 -0
  32. package/scripts/lib/resolve-plugins.mjs +31 -6
  33. package/scripts/lib/run-conftest-batch.mjs +16 -11
  34. package/scripts/utils/docs/index.md +1 -0
  35. package/scripts/utils/docs/spawn-async.md +33 -0
  36. package/scripts/utils/spawn-async.mjs +112 -0
  37. package/skills/taze/SKILL.md +8 -1
  38. package/skills/taze/js/docs/index.md +6 -6
  39. package/skills/taze/js/docs/orchestrate.md +12 -17
  40. package/skills/taze/js/docs/rust-provider.md +42 -0
  41. package/skills/taze/js/orchestrate.mjs +113 -334
  42. package/skills/taze/js/rust-provider.mjs +162 -0
  43. package/skills/taze/js/docs/uv-diff.md +0 -41
  44. package/skills/taze/js/uv-diff.mjs +0 -176
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Bounded two-lane concurrent scheduler для `detectAll()` (ADR 260716-1354). Активний лише
3
+ * коли `concurrency > 1` (деталі — `run-detectors.mjs`); за замовчуванням (`concurrency === 1`)
4
+ * `detectAll` лишається на повністю послідовному шляху, спостережувано ідентичному до-ADR
5
+ * поведінці — ця функція там навіть не викликається.
6
+ *
7
+ * Два лейни за `isSerial(item)`: **serial lane** — власний sequential runner (mutex за
8
+ * конструкцією, items ніколи не перекриваються самі з собою); **parallel lane** — bounded
9
+ * pool до `concurrency` слотів. Обидва лейни виконуються конкурентно один з одним — свідомий
10
+ * вибір: serial-lane item, коли реально виконує свій blocking `spawnSync`, все одно заморожує
11
+ * весь event loop (тобто "конкурентність" із parallel lane суто структурна, не робить
12
+ * serial-lane item швидшим), а parallel-lane пул отримує реальну вигоду від одночасного
13
+ * очікування кількох `spawnAsync`-викликів.
14
+ *
15
+ * Перший виняток від `runItem` зупиняє нові старти в обох лейнах, `controller.abort()`
16
+ * сигналізує вже запущеним async-детекторам, і функція чекає завершення всіх уже
17
+ * стартованих items (кожен `runOne` сам ловить власну помилку — жоден виклик не відхиляє
18
+ * зовнішній `Promise.all`) перед поверненням.
19
+ */
20
+
21
+ /**
22
+ * @template T, R
23
+ * @typedef {object} PlanItemOutcome
24
+ * @property {T} item вхідний item
25
+ * @property {R} [result] результат `runItem`, якщо завершився успішно
26
+ * @property {unknown} [error] помилка `runItem` (перша — стає `infraError`)
27
+ * @property {boolean} [aborted] true — item отримав `AbortError` уже ПІСЛЯ того, як інший item
28
+ * зупинив плановий прогін (очікуване скасування, не нова інфра-помилка)
29
+ */
30
+
31
+ /**
32
+ * @template T, R
33
+ * @param {T[]} items вхідний план (в оригінальному порядку)
34
+ * @param {object} opts опції планування
35
+ * @param {number} opts.concurrency bounded pool розмір для parallel lane (мінімум 1)
36
+ * @param {(item: T) => boolean} opts.isSerial чи item належить serial lane
37
+ * @param {(item: T, signal: AbortSignal) => Promise<R>} opts.runItem виконує один item;
38
+ * кидання зупиняє планування нових items і абортить `signal`
39
+ * @returns {Promise<{ results: PlanItemOutcome<T, R>[], infraError: unknown|null }>}
40
+ * `results` — лише items, що реально стартували (в порядку завершення, не вхідному);
41
+ * `infraError` — перша помилка `runItem`, або `null`, якщо всі items завершились успішно
42
+ */
43
+ export async function runPlanConcurrently(items, { concurrency, isSerial, runItem }) {
44
+ const controller = new AbortController()
45
+ const parallelItems = []
46
+ const serialItems = []
47
+ for (const item of items) (isSerial(item) ? serialItems : parallelItems).push(item)
48
+
49
+ /** @type {PlanItemOutcome<T, R>[]} */
50
+ const results = []
51
+ let infraError = null
52
+ let stopped = false
53
+
54
+ const runOne = async item => {
55
+ if (stopped) return
56
+ try {
57
+ const result = await runItem(item, controller.signal)
58
+ results.push({ item, result })
59
+ } catch (error) {
60
+ if (stopped && error?.name === 'AbortError') {
61
+ results.push({ item, aborted: true })
62
+ return
63
+ }
64
+ results.push({ item, error })
65
+ if (!stopped) {
66
+ stopped = true
67
+ infraError = error
68
+ controller.abort()
69
+ }
70
+ }
71
+ }
72
+
73
+ const runSerialLane = async () => {
74
+ for (const item of serialItems) {
75
+ if (stopped) break
76
+ await runOne(item)
77
+ }
78
+ }
79
+
80
+ const runParallelLane = async () => {
81
+ let next = 0
82
+ const worker = async () => {
83
+ while (!stopped) {
84
+ const i = next++
85
+ if (i >= parallelItems.length) return
86
+ await runOne(parallelItems[i])
87
+ }
88
+ }
89
+ const workerCount = Math.min(concurrency, parallelItems.length)
90
+ await Promise.all(Array.from({ length: workerCount }, worker))
91
+ }
92
+
93
+ await Promise.all([runSerialLane(), runParallelLane()])
94
+
95
+ return { results, infraError }
96
+ }
@@ -21,6 +21,9 @@
21
21
  * `undefined` означає whole-repo
22
22
  * @property {boolean} [verbose] `--verbose` CLI-прапорець; concern-и із зовнішніми
23
23
  * інструментами (напр. `ga/workflows`) звіряються з ним, щоб не засмічувати прогрес-бар `lint --full`
24
+ * @property {AbortSignal} [signal] сигнал скасування — лише у parallel lane `detectAll()`
25
+ * (`N_RULES_LINT_CONCURRENCY>1`, ADR 260716-1354); async-детектори (`spawnAsync`-based)
26
+ * прокидають його далі, щоб перерватись при infrastructure-помилці іншого concern-а
24
27
  */
25
28
 
26
29
  /**
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Публічний API для плагінів `@7n/rules` (експорт `@7n/rules/plugin-api`).
3
+ *
4
+ * Фаза 1 (spec 2026-07-18-lang-plugins-extraction): один порт — `EcosystemProvider`
5
+ * для taze. Плагін реєструє провайдера через маніфест package.json:
6
+ * `"n-rules": { "contributes": { "handlers": { "taze": "./taze/provider.mjs" } } }`;
7
+ * модуль-обробник експортує обʼєкт провайдера як `default`. Наступні порти
8
+ * (doc-files, lint) додаються окремими фазами — не проєктуються наперед.
9
+ *
10
+ * Semver-утиліти ядра реекспортуються звідси, щоб плагіни класифікували
11
+ * major/minor за тим самим правилом caret-семантики, що й ядро, без
12
+ * дублювання коду і без імпорту внутрішніх шляхів `@7n/rules`.
13
+ */
14
+ export { isBreaking, parseVersion } from '../../skills/taze/js/diff.mjs'
15
+
16
+ /** Версія контракту plugin-api: плагін декларує `requiresPluginApi`, несумісність → skip, не креш. */
17
+ export const PLUGIN_API_VERSION = 1
18
+
19
+ /**
20
+ * @typedef {(cmd: string, args: string[], opts?: object) => { status: number|null, stdout: string, stderr: string }} SpawnFn
21
+ * Сумісний зі `spawnSync` виклик зовнішньої команди (інжектовний у тестах).
22
+ */
23
+
24
+ /**
25
+ * @typedef {object} EcosystemAvailability
26
+ * @property {boolean} ok чи доступний тулчейн екосистеми (напр. `uv --version` → exit 0)
27
+ * @property {string|null} reason зрозуміла людині причина недоступності для звіту (null, якщо ok)
28
+ */
29
+
30
+ /**
31
+ * @typedef {object} EcosystemProvider
32
+ * Порт однієї екосистеми залежностей для taze-оркестратора. Ядро викликає методи
33
+ * в порядку: `detect` → (`available`) → `backup` → `bump` → `diff` → по кожному
34
+ * major-запису `promptFor` → `cleanup`. Провал `bump`/`diff` одного провайдера
35
+ * не зупиняє інших.
36
+ * @property {string} id стабільний ідентифікатор (напр. `python-uv`)
37
+ * @property {string} title заголовок секції звіту (напр. `Python-пакети (uv)`)
38
+ * @property {string} manifestNoun назва маніфеста для звіту (напр. `pyproject.toml`)
39
+ * @property {string} skillSection посилання на ручну гілку SKILL.md у звіті-skip (напр. `Python-гілкою SKILL.md`)
40
+ * @property {(cwd: string, deps: { spawnFn: SpawnFn }) => string[]} detect відносні шляхи знайдених маніфестів (порожньо → екосистеми в проєкті немає, тиша у звіті)
41
+ * @property {(spawnFn: SpawnFn) => EcosystemAvailability} available чи встановлений тулчейн; `ok:false` → graceful skip із `reason` у звіті
42
+ * @property {(cwd: string, manifests: string[], deps: object) => Promise<void>} backup бекап маніфестів/lock-файлів (`<file>.taze-bak`)
43
+ * @property {(cwd: string, manifests: string[], ctx: { spawnFn: SpawnFn, log: (line: string) => void, deps: object }) => Promise<void>} bump масовий bump до latest (включно з major)
44
+ * @property {(cwd: string, manifests: string[], deps: object) => Promise<{major: Array<{manifest: string, pkg: string, from: string, to: string}>, minorPatch: number, totalChanged: number}>} diff детермінована класифікація major vs minor/patch (бекап vs поточний)
45
+ * @property {(entry: {manifest: string, pkg: string, from: string, to: string}) => string} promptFor промпт одного ізольованого виклику раннера для одного major-запису
46
+ * @property {(cwd: string, manifests: string[], deps: object) => Promise<void>} cleanup прибирання бекапів
47
+ */
48
+
49
+ const REQUIRED_PROVIDER_FUNCTIONS = ['detect', 'available', 'backup', 'bump', 'diff', 'promptFor', 'cleanup']
50
+ const REQUIRED_PROVIDER_STRINGS = ['id', 'title', 'manifestNoun', 'skillSection']
51
+
52
+ /**
53
+ * Валідує форму провайдера з модуля плагіна — зрозуміла помилка замість
54
+ * «undefined is not a function» глибоко в оркестраторі.
55
+ * @param {unknown} candidate default-експорт handler-модуля плагіна
56
+ * @param {string} source ім'я плагіна/шлях модуля для повідомлення
57
+ * @returns {EcosystemProvider} той самий обʼєкт, якщо валідний
58
+ */
59
+ export function assertEcosystemProvider(candidate, source) {
60
+ if (!candidate || typeof candidate !== 'object') {
61
+ throw new TypeError(`plugin-api: ${source} — default-експорт не є обʼєктом EcosystemProvider`)
62
+ }
63
+ const missing = [
64
+ ...REQUIRED_PROVIDER_STRINGS.filter(k => typeof candidate[k] !== 'string' || candidate[k] === ''),
65
+ ...REQUIRED_PROVIDER_FUNCTIONS.filter(k => typeof candidate[k] !== 'function')
66
+ ]
67
+ if (missing.length > 0) {
68
+ throw new TypeError(`plugin-api: ${source} — EcosystemProvider без обовʼязкових полів: ${missing.join(', ')}`)
69
+ }
70
+ return /** @type {EcosystemProvider} */ (candidate)
71
+ }
@@ -22,12 +22,17 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'
22
22
  import { spawnSync } from 'node:child_process'
23
23
  import { join, resolve } from 'node:path'
24
24
 
25
- /** Відомі плагіни для автовизначення: сигнал у дереві репо → npm-пакет. */
25
+ /** Відомі CI-плагіни для автовизначення: сигнал у дереві репо → npm-пакет. */
26
26
  export const KNOWN_CI_PLUGINS = Object.freeze({
27
27
  github: '@7n/rules-ci-github',
28
28
  azure: '@7n/rules-ci-azure'
29
29
  })
30
30
 
31
+ /** Відомі мовні плагіни: файловий сигнал екосистеми в корені репо → npm-пакет. */
32
+ export const KNOWN_LANG_PLUGINS = Object.freeze({
33
+ python: { signal: 'pyproject.toml', pkg: '@7n/rules-lang-python' }
34
+ })
35
+
31
36
  const WORKFLOW_YML_RE = /\.ya?ml$/u
32
37
  const GITHUB_URL_RE = /github\.com/iu
33
38
  const AZURE_URL_RE = /dev\.azure\.com|visualstudio\.com/iu
@@ -72,9 +77,9 @@ function hasGithubWorkflows(projectRoot) {
72
77
  * Файлові сигнали мають пріоритет; `repository.url` — лише коли файлових сигналів нема
73
78
  * (свіже репо без CI-конфігів). Обидва сигнали → обидва плагіни; жодного → [].
74
79
  * @param {string} projectRoot корінь репозиторію
75
- * @returns {string[]} npm-імена плагінів
80
+ * @returns {string[]} npm-імена CI-плагінів
76
81
  */
77
- export function detectPluginsFromRepo(projectRoot) {
82
+ function detectCiPlugins(projectRoot) {
78
83
  const out = []
79
84
  if (hasGithubWorkflows(projectRoot)) out.push(KNOWN_CI_PLUGINS.github)
80
85
  if (existsSync(join(projectRoot, 'azure-pipelines.yml'))) out.push(KNOWN_CI_PLUGINS.azure)
@@ -88,6 +93,21 @@ export function detectPluginsFromRepo(projectRoot) {
88
93
  return out
89
94
  }
90
95
 
96
+ /**
97
+ * Автодетект плагінів за станом репозиторію: CI-плагіни (файлові сигнали з
98
+ * fallback на `repository.url`) + мовні плагіни (лише файлові сигнали —
99
+ * маніфест екосистеми в корені; URL-fallback для мов безглуздий).
100
+ * @param {string} projectRoot корінь репозиторію
101
+ * @returns {string[]} npm-імена плагінів
102
+ */
103
+ export function detectPluginsFromRepo(projectRoot) {
104
+ const out = detectCiPlugins(projectRoot)
105
+ for (const { signal, pkg } of Object.values(KNOWN_LANG_PLUGINS)) {
106
+ if (existsSync(join(projectRoot, signal))) out.push(pkg)
107
+ }
108
+ return out
109
+ }
110
+
91
111
  /**
92
112
  * Список плагінів проєкту: явний `config.plugins` (включно з порожнім = вимкнено) або автодетект.
93
113
  * @param {string} projectRoot корінь репозиторію
@@ -183,12 +203,15 @@ export function resolvePlugins(projectRoot, config, options = {}) {
183
203
  }
184
204
  continue
185
205
  }
206
+ const manifest = readPluginManifest(packageRoot)
186
207
  const rulesDir = join(packageRoot, 'rules')
187
- if (!existsSync(rulesDir)) {
208
+ if (manifest.contributes.rules && !existsSync(rulesDir)) {
209
+ // Плагін ДЕКЛАРУЄ правила (rules !== false), але каталогу нема — битий пакет.
210
+ // Плагін без правил (лише handlers, напр. lang-* до фази 3) — легальний.
188
211
  if (options.quiet !== true) console.warn(`⚠️ Плагін ${name} без каталогу rules/ — пропускаю\n`)
189
212
  continue
190
213
  }
191
- out.push({ name, packageRoot, rulesDir, manifest: readPluginManifest(packageRoot) })
214
+ out.push({ name, packageRoot, rulesDir, manifest })
192
215
  }
193
216
  RESOLVE_CACHE.set(cacheKey, out)
194
217
  return out
@@ -207,7 +230,9 @@ export function resolveRulesDirs(projectRoot, config, bundledRulesDir, options =
207
230
  const plugins = resolvePlugins(projectRoot, config, options)
208
231
  return [
209
232
  { name: '@7n/rules', rulesDir: bundledRulesDir, packageRoot: null },
210
- ...plugins.map(p => ({ name: p.name, rulesDir: p.rulesDir, packageRoot: p.packageRoot }))
233
+ ...plugins
234
+ .filter(p => p.manifest.contributes.rules && existsSync(p.rulesDir))
235
+ .map(p => ({ name: p.name, rulesDir: p.rulesDir, packageRoot: p.packageRoot }))
211
236
  ]
212
237
  }
213
238
 
@@ -9,16 +9,20 @@
9
9
  * ризик дрифту (типу `spec.config` vs `spec.default.config` у
10
10
  * `health_check_policy.rego`, що ми ловили cross-check тестами).
11
11
  *
12
- * Hard-fail на відсутність `conftest` — через `ensureTool`, що спочатку
12
+ * Hard-fail на відсутність `conftest` — через `ensureToolAsync`, що спочатку
13
13
  * намагається авто-встановити, і лише після невдачі кидає виняток.
14
+ *
15
+ * Async (`spawnAsync`, не `spawnSync`) — детектор не блокує event loop, тож може
16
+ * виконуватись у parallel lane `detectAll()` (ADR 260716-1354). Приймає опційний
17
+ * `signal`/`timeoutMs` — прокидаються в `spawnAsync`.
14
18
  */
15
- import { spawnSync } from 'node:child_process'
16
19
  import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
17
20
  import { tmpdir } from 'node:os'
18
21
  import { dirname, join } from 'node:path'
19
22
  import { fileURLToPath } from 'node:url'
20
23
 
21
- import { ensureTool } from './ensure-tool.mjs'
24
+ import { ensureToolAsync } from './ensure-tool.mjs'
25
+ import { spawnAsync } from '../utils/spawn-async.mjs'
22
26
 
23
27
  /**
24
28
  Каталог пакета `@7n/rules`, від якого ресолвимо вшиті директорії правил.
@@ -44,6 +48,8 @@ const RULES_ROOT = join(PACKAGE_ROOT, 'rules')
44
48
  * @property {string[]} files список абсолютних шляхів файлів для перевірки (порожній — повертаємо порожньо)
45
49
  * @property {string[]} [extraArgs] додаткові аргументи для conftest (наприклад `--combine` для крос-документних правил)
46
50
  * @property {object} [templateData] опціональне merged-дерево; серіалізується у JSON `{ "template": <data> }` і передається як `--data <tmpfile>` (cleanup після завершення)
51
+ * @property {AbortSignal} [signal] сигнал скасування — прокидається у `spawnAsync`
52
+ * @property {number} [timeoutMs] ліміт виконання `conftest` у мілісекундах — прокидається у `spawnAsync`
47
53
  */
48
54
 
49
55
  /**
@@ -65,11 +71,11 @@ export function buildConftestArgs(p) {
65
71
  * порушень. Якщо `files` порожній — повертає `[]` без спавна. Якщо `conftest`
66
72
  * не у PATH і авто-встановлення не вдалось — кидає виняток (hard fail).
67
73
  * @param {ConftestBatchOptions} opts параметри запуску
68
- * @returns {ConftestViolation[]} масив порушень (порожній — все ок)
74
+ * @returns {Promise<ConftestViolation[]>} масив порушень (порожній — все ок)
69
75
  */
70
- export function runConftestBatch(opts) {
76
+ export async function runConftestBatch(opts) {
71
77
  if (opts.files.length === 0) return []
72
- const conftestBin = ensureTool('conftest')
78
+ const conftestBin = await ensureToolAsync('conftest')
73
79
  // policyDirRel — формат `<rule>/<concern>` (наприклад `abie/base_deployment_preem`).
74
80
  // Flat concern path: rules/<rule>/<concern>/ (без проміжного `policy/`).
75
81
  const slash = opts.policyDirRel.indexOf('/')
@@ -94,11 +100,10 @@ export function runConftestBatch(opts) {
94
100
  extraArgs: opts.extraArgs ?? [],
95
101
  tmpDataFile
96
102
  })
97
- const result = spawnSync(conftestBin, args, { encoding: 'utf8' })
98
- if (result.error) throw result.error
99
- // conftest exit 1 = є failures (це валідно для нас); >1 = справжня помилка.
100
- if (result.status !== 0 && result.status !== 1) {
101
- throw new Error(`conftest exit ${result.status}: ${(result.stderr || result.stdout || '').slice(0, 500)}`)
103
+ const result = await spawnAsync(conftestBin, args, { signal: opts.signal, timeoutMs: opts.timeoutMs })
104
+ // conftest exit 1 = є failures (це валідно для нас); >1 (або null — вбито сигналом/таймаутом) = справжня помилка.
105
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
106
+ throw new Error(`conftest exit ${result.exitCode}: ${(result.stderr || result.stdout || '').slice(0, 500)}`)
102
107
  }
103
108
  /**
104
109
  @type {Array<{ filename: string, namespace: string, failures?: Array<{ msg: string }> }>}
@@ -16,6 +16,7 @@ resource: npm/scripts/utils/
16
16
  | [resolve-cargo-manifest.mjs](resolve-cargo-manifest.md) | JS Module |
17
17
  | [resolve-cmd.mjs](resolve-cmd.md) | JS Module |
18
18
  | [resolve-js-root.mjs](resolve-js-root.md) | JS Module |
19
+ | [spawn-async.mjs](spawn-async.md) | JS Module |
19
20
  | [test-helpers.mjs](test-helpers.md) | JS Module |
20
21
  | [walk-cache.mjs](walk-cache.md) | JS Module |
21
22
  | [walkDir.mjs](walkDir.md) | JS Module |
@@ -0,0 +1,33 @@
1
+ ---
2
+ type: JS Module
3
+ title: spawn-async.mjs
4
+ resource: npm/scripts/utils/spawn-async.mjs
5
+ docgen:
6
+ crc: cc5d92e0
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 100
10
+ issues: judge:inaccurate:0.98
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ Забезпечує асинхронну заміну `spawnSync` для важких зовнішніх CLI на кшталт `conftest` і `oxlint`, щоб не блокувати Node event loop і не створювати ілюзію паралельності під час запуску кількох detector’ів. Обгортає `child_process.spawn` через `events.once`, підтримує зовнішній `AbortSignal` і `timeoutMs` з ескалацією `SIGTERM` → `SIGKILL`, а результат повертає нормалізовано без винятку на non-zero exit — рішення про це лишається за caller.
17
+
18
+ ## Поведінка
19
+
20
+ 1. `spawnAsync` запускає зовнішній CLI асинхронно, щоб не блокувати event loop під час важких системних перевірок.
21
+ 2. Якщо запуск уже скасовано до старту, одразу повертає помилку скасування.
22
+ 3. Збирає `stdout` і `stderr` у нормалізований текстовий результат для подальшого аналізу викликачем.
23
+ 4. Підтримує зовнішнє скасування та часовий ліміт: у цих випадках спершу намагається завершити процес м’яко, а потім примусово, якщо він не зупинився в межах grace-періоду.
24
+ 5. Повертає код завершення, сигнал завершення та ознаки скасування або timeout як звичайний результат, а не як виняток.
25
+ 6. Кидає помилку лише тоді, коли сам запуск процесу не відбувся або середовище не змогло стартувати команду; non-zero exit лишається відповідальністю викликачa.
26
+
27
+ ## Публічний API
28
+
29
+ - spawnAsync — асинхронно запускає зовнішню команду, дочікується завершення, збирає її вихід і повертає результат; не падає через code ≠ 0, а кидає лише коли процес не вдалося створити або якщо сигнал уже був aborted до старту
30
+
31
+ ## Гарантії поведінки
32
+
33
+ - Read-only: не виконує операцій запису (ФС/БД).
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Async (non-blocking) заміна `spawnSync` для важких зовнішніх CLI-викликів (conftest, oxlint тощо).
3
+ *
4
+ * `spawnSync` блокує весь Node event loop цілком — паралельний виклик кількох
5
+ * concern-детекторів навколо `spawnSync` не дає реальної паралельності, лише ілюзію.
6
+ * `spawnAsync` обгортає `child_process.spawn` через `events.once` (без `new Promise`,
7
+ * `promise/avoid-new` заборонений у цьому пакеті), підтримує зовнішній `AbortSignal`
8
+ * і `timeoutMs` (обидва ведуть до `SIGTERM` → ескалація `SIGKILL`, якщо процес не
9
+ * завершився за grace-період), і повертає нормалізований результат без винятку на
10
+ * non-zero exit — це, як і раніше, вирішує caller.
11
+ */
12
+ import { spawn } from 'node:child_process'
13
+ import { once } from 'node:events'
14
+
15
+ /**
16
+ * @typedef {object} SpawnAsyncResult
17
+ * @property {string} stdout зібраний stdout (utf8)
18
+ * @property {string} stderr зібраний stderr (utf8)
19
+ * @property {number|null} exitCode код завершення (`null` — процес вбито сигналом)
20
+ * @property {string|null} signal сигнал, яким вбито процес (`null` — завершився сам)
21
+ * @property {boolean} timedOut true, якщо процес вбито через `timeoutMs`
22
+ * @property {boolean} aborted true, якщо процес вбито через зовнішній `AbortSignal`
23
+ */
24
+
25
+ /** `AbortError` (DOM `AbortController` семантика) для вже-скасованого `signal` до старту спавна. */
26
+ class AbortError extends Error {
27
+ /** @param {string} [message] текст помилки */
28
+ constructor(message = 'The operation was aborted') {
29
+ super(message)
30
+ this.name = 'AbortError'
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Запускає зовнішній процес асинхронно (не блокує event loop) і збирає його результат.
36
+ * Ніколи не кидає на non-zero exit — кидає лише на `spawn`-помилку (ENOENT тощо) або
37
+ * якщо `opts.signal` вже `aborted` до виклику.
38
+ * @param {string} cmd бінарник (шлях або ім'я в PATH)
39
+ * @param {string[]} args аргументи запуску
40
+ * @param {object} [opts] опції
41
+ * @param {AbortSignal} [opts.signal] зовнішній сигнал скасування
42
+ * @param {number} [opts.timeoutMs] ліміт у мілісекундах (без ліміту — не задано / ≤0)
43
+ * @param {number} [opts.killGraceMs] пауза між `SIGTERM` і ескалацією до `SIGKILL` (дефолт 5000)
44
+ * @param {string} [opts.cwd] робочий каталог дочірнього процесу
45
+ * @param {Record<string, string>} [opts.env] оточення дочірнього процесу
46
+ * @returns {Promise<SpawnAsyncResult>} нормалізований результат виконання
47
+ */
48
+ export async function spawnAsync(cmd, args, opts = {}) {
49
+ const { signal, timeoutMs, killGraceMs = 5000, ...spawnOpts } = opts
50
+ if (signal?.aborted) throw new AbortError()
51
+
52
+ const child = spawn(cmd, args, spawnOpts)
53
+ child.stdout?.setEncoding('utf8')
54
+ child.stderr?.setEncoding('utf8')
55
+
56
+ let stdout = ''
57
+ let stderr = ''
58
+ child.stdout?.on('data', chunk => {
59
+ stdout += chunk
60
+ })
61
+ child.stderr?.on('data', chunk => {
62
+ stderr += chunk
63
+ })
64
+
65
+ let timedOut = false
66
+ let aborted = false
67
+ let settled = false
68
+ let killTimer = null
69
+ let timeoutTimer = null
70
+
71
+ /** SIGTERM негайно, ескалація до SIGKILL якщо процес не завершився за killGraceMs. */
72
+ const killWithEscalation = () => {
73
+ child.kill('SIGTERM')
74
+ killTimer = setTimeout(() => {
75
+ if (!settled) child.kill('SIGKILL')
76
+ }, killGraceMs)
77
+ killTimer.unref?.()
78
+ }
79
+ const onAbort = () => {
80
+ aborted = true
81
+ killWithEscalation()
82
+ }
83
+ if (signal) signal.addEventListener('abort', onAbort)
84
+ if (timeoutMs && timeoutMs > 0) {
85
+ timeoutTimer = setTimeout(() => {
86
+ timedOut = true
87
+ killWithEscalation()
88
+ }, timeoutMs)
89
+ timeoutTimer.unref?.()
90
+ }
91
+
92
+ /** @returns {Promise<{code: number|null, killSignal: string|null}>} результат події `close` */
93
+ const waitForClose = async () => {
94
+ const [code, killSignal] = await once(child, 'close')
95
+ return { code, killSignal }
96
+ }
97
+ /** @returns {Promise<never>} ніколи не резолвиться — кидає подію `error` */
98
+ const waitForError = async () => {
99
+ const [error] = await once(child, 'error')
100
+ throw error
101
+ }
102
+
103
+ try {
104
+ const { code, killSignal } = await Promise.race([waitForClose(), waitForError()])
105
+ return { stdout, stderr, exitCode: code, signal: killSignal, timedOut, aborted }
106
+ } finally {
107
+ settled = true
108
+ if (killTimer) clearTimeout(killTimer)
109
+ if (timeoutTimer) clearTimeout(timeoutTimer)
110
+ if (signal) signal.removeEventListener('abort', onAbort)
111
+ }
112
+ }
@@ -20,10 +20,17 @@ version: '1.2'
20
20
  прогін міг зависати без жодної діагностики (один величезний непрозорий хід на
21
21
  весь монорепо, без проміжних зупинок для перевірки прогресу). Замість цього `npm/skills/taze/js/orchestrate.mjs`:
22
22
 
23
- 1. Детерміновано, без LLM, виконує кроки 1-3 нижче для всіх гілок: npm/bun (бекап → `bunx taze -w -r latest` → `bun install` → `n-rules taze diff`); якщо знайдені `Cargo.toml`, Rust (бекап → `cargo upgrade --incompatible allow` → `cargo update` → `collectCargoDiff` з `cargo-diff.mjs`, детермінований cargo-еквівалент `n-rules taze diff` — парсить кожен `Cargo.toml` через `smol-toml`, класифікує major за тим самим правилом caret-семантики, включно з Cargo-скороченими версіями `"1"`/`"0.4"`); якщо знайдений кореневий `pyproject.toml`, Python (бекап → по кожній прямій залежності `uv remove <pkg>` + `uv add <pkg>[extras] --bounds lower` → `collectUvDiff` з `uv-diff.mjs`, парсить PEP 508/PEP 440 через `smol-toml`, той самий caret-принцип класифікації).
23
+ 1. Детерміновано, без LLM, виконує кроки 1-3 нижче для всіх гілок: npm/bun (бекап → `bunx taze -w -r latest` → `bun install` → `n-rules taze diff`); якщо знайдені `Cargo.toml`, Rust (бекап → `cargo upgrade --incompatible allow` → `cargo update` → `collectCargoDiff` з `cargo-diff.mjs`, детермінований cargo-еквівалент `n-rules taze diff` — парсить кожен `Cargo.toml` через `smol-toml`, класифікує major за тим самим правилом caret-семантики, включно з Cargo-скороченими версіями `"1"`/`"0.4"`); якщо знайдений кореневий `pyproject.toml`, Python (бекап → по кожній прямій залежності `uv remove <pkg>` + `uv add <pkg>[extras] --bounds lower` → `collectUvDiff`, парсить PEP 508/PEP 440 через `smol-toml`, той самий caret-принцип класифікації).
24
24
  2. Для кожного **окремого** major-пакета/крейта з diff-у — один ізольований, обмежений виклик обраного раннера (кроки 4-6, лише для цього запису; промпт генерує `buildDependencyPrompt`/`buildCargoDependencyPrompt`/`buildUvDependencyPrompt`, не цей SKILL.md).
25
25
  3. Детерміновано прибирає бекапи (крок 7) і компонує звіт (крок 8) з результатів усіх ітерацій.
26
26
 
27
+ Не-npm екосистеми оркестратор веде через **EcosystemProvider-порт**
28
+ (`@7n/rules/plugin-api`, spec `docs/specs/2026-07-18-lang-plugins-extraction-spec.md`):
29
+ Rust — вбудований first-party провайдер (`rust-provider.mjs`, до фази 2), Python —
30
+ плагін **`@7n/rules-lang-python`** (автодетект за кореневим `pyproject.toml`,
31
+ доставляється автоматично при першому запуску; extension-point `taze`). На
32
+ чисто-JS проєкті без цих сигналів у звіті немає навіть згадки про інші екосистеми.
33
+
27
34
  Якщо `Cargo.toml` знайдені, але `cargo-edit` не встановлено — Rust-гілка **не**
28
35
  блокує інші гілки: оркестратор пропускає Rust-обробку і в звіті перелічує
29
36
  знайдені `Cargo.toml` як такі, що потребують ручного прогону (нижче). Так само,
@@ -4,9 +4,9 @@ title: npm/skills/taze/js
4
4
  resource: npm/skills/taze/js/
5
5
  ---
6
6
 
7
- | Файл | Тип |
8
- | --------------------------------- | --------- |
9
- | [cargo-diff.mjs](cargo-diff.md) | JS Module |
10
- | [diff.mjs](diff.md) | JS Module |
11
- | [orchestrate.mjs](orchestrate.md) | JS Module |
12
- | [uv-diff.mjs](uv-diff.md) | JS Module |
7
+ | Файл | Тип |
8
+ | ------------------------------------- | --------- |
9
+ | [cargo-diff.mjs](cargo-diff.md) | JS Module |
10
+ | [diff.mjs](diff.md) | JS Module |
11
+ | [orchestrate.mjs](orchestrate.md) | JS Module |
12
+ | [rust-provider.mjs](rust-provider.md) | JS Module |
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: orchestrate.mjs
4
4
  resource: npm/skills/taze/js/orchestrate.mjs
5
5
  docgen:
6
- crc: 56fd9864
6
+ crc: a1a1ade0
7
7
  model: openai-codex/gpt-5.4-mini
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -12,34 +12,29 @@ docgen:
12
12
 
13
13
  ## Огляд
14
14
 
15
- Оркеструє `taze` для трьох екосистем npm/bun, Rust (Cargo) і Python (uv) — детерміновано, без LLM: бекап маніфестів → масовий bump (`bunx taze`/`cargo upgrade`/по-пакетний `uv remove`+`uv add --bounds lower`) → diff класифікація major vs minor/patch → прибирання бекапів → Markdown-звіт (`formatReport`). Для КОЖНОГО окремого major-пакета/крейта з diff-у виконує один ізольований, обмежений виклик обраного раннера (`buildDependencyPrompt`/`buildCargoDependencyPrompt`/`buildUvDependencyPrompt` + `callRunner`) — лише перевірка сумісності й рефакторинг, не сам bump. Файл виконує реальні файлові операції (копіює/видаляє бекапи `package.json`/`Cargo.toml`/`Cargo.lock`/`pyproject.toml`/`uv.lock`) і запускає зовнішні команди (`bunx`, `bun`, `cargo`, `uv`, `git`, `find`) — не read-only.
15
+ Оркеструє taze детерміновано, без LLM для службових кроків: бекап маніфестів → масовий bump → diff-класифікація major vs minor/patch → прибирання бекапів → Markdown-звіт. Для КОЖНОГО окремого major-пакета з diff-у виконує один ізольований, обмежений виклик обраного раннера — лише перевірка сумісності й рефакторинг, не сам bump. npm/bun-гілка вбудована; решта екосистем — `EcosystemProvider`-и (контракт `@7n/rules/plugin-api`): Rust як first-party (`rust-provider.mjs`), інші — з плагінів (`@7n/rules-lang-*`, extension-point `taze`). Файл виконує реальні файлові операції (бекапи) і запускає зовнішні команди (`bunx`, `bun`, `git`, команди провайдерів) — не read-only.
16
16
 
17
17
  ## Поведінка
18
18
 
19
- - **buildDependencyPrompt** / **buildCargoDependencyPrompt** / **buildUvDependencyPrompt** формують промпт ОДНОГО ітеративного виклику (лише перевірка сумісності й рефакторинг) для одного major-пакета npm/Cargo/uv відповідно.
19
+ - **buildDependencyPrompt** — формує промпт ОДНОГО ітеративного виклику (лише перевірка сумісності й рефакторинг) для одного major-пакета npm/bun.
20
20
  - **callRunner** — диспетчер одного ітеративного виклику: `pi` — вбудований pi-агент (текст через `deps.out`), `cursor`/`codex` — napi-міст ACP (`@7n/llm-lib/acp`).
21
21
  - **backupWorkspacePackageFiles** / **cleanupBackups** — бекап і прибирання `package.json` кожного воркспейсу.
22
- - **backupCargoManifests** / **cleanupCargoBackups** бекап і прибирання кожного `Cargo.toml` + спільного кореневого `Cargo.lock`.
23
- - **backupUvManifest** / **cleanupUvBackups** бекап і прибирання кореневого `pyproject.toml` + `uv.lock`.
24
- - **findCargoManifests** — знаходить усі `Cargo.toml` поза `node_modules`/`.worktrees`/`target`.
25
- - **findPyprojectManifest** — перевіряє наявність кореневого `pyproject.toml` (uv-конвенція — один файл, не per-package обхід).
26
- - **formatReport** — компонує підсумковий Markdown-звіт (minor/patch, major-оновлення по кожній з трьох гілок, загальний обсяг змін) без окремого LLM-виклику.
27
- - **runTazeOrchestrator** — повний прогін: перевіряє, що `cwd` — ізольований worktree, послідовно обробляє npm-, Rust- (за наявності `Cargo.toml` і `cargo-edit`) і Python-гілку (за наявності `pyproject.toml` і `uv`) — бекап → bump → diff → ізольовані виклики раннера по кожному major-запису → прибирання — і повертає звіт.
22
+ - **loadPluginTazeProviders** завантажує провайдерів з активних плагінів: `.n-rules.json`/автодетект `resolvePlugins` (плагін доставляється автоматично при першому запуску) → handler-модулі extension-point `taze` → валідація `assertEcosystemProvider`; битий плагін — warning і пропуск, не провал.
23
+ - **formatReport** компонує підсумковий Markdown-звіт (npm-гілка + секція на кожну екосистему з manifests; екосистема без manifests — тиша) без окремого LLM-виклику.
24
+ - **runTazeOrchestrator** — повний прогін: перевіряє, що `cwd` ізольований worktree, виконує npm-гілку, далі кожного провайдера наскрізь (detect → available → backup → bump → diff → ізольовані виклики раннера по major-записах → cleanup) і повертає звіт.
28
25
 
29
26
  ## Публічний API
30
27
 
31
- - buildDependencyPrompt / buildCargoDependencyPrompt / buildUvDependencyPrompt — промпт одного major-запису відповідної екосистеми.
28
+ - buildDependencyPrompt — промпт одного npm-major-запису.
32
29
  - callRunner — виклик обраного раннера (`pi`/`cursor`/`codex`) з одним промптом.
33
30
  - backupWorkspacePackageFiles / cleanupBackups — бекап/прибирання `package.json` воркспейсів.
34
- - backupCargoManifests / cleanupCargoBackups бекап/прибирання `Cargo.toml`/`Cargo.lock`.
35
- - backupUvManifest / cleanupUvBackups бекап/прибирання `pyproject.toml`/`uv.lock`.
36
- - findCargoManifestsсписок знайдених `Cargo.toml` у репозиторії.
37
- - findPyprojectManifest — список (0 або 1 запис) кореневого `pyproject.toml`.
38
- - formatReport — фінальний Markdown-звіт із результатів усіх трьох гілок.
39
- - runTazeOrchestrator — повна оркестрація taze для npm/bun + Rust + Python.
31
+ - loadPluginTazeProviders валідні EcosystemProvider-и з handler-модулів плагінів.
32
+ - formatReport фінальний Markdown-звіт із npm-результатів і записів екосистем.
33
+ - runTazeOrchestratorповна оркестрація taze; `deps.ecosystemProviders` повністю замінює список провайдерів (для тестів).
40
34
 
41
35
  ## Гарантії поведінки
42
36
 
43
37
  - Виконує файлові операції (копіювання/видалення бекапів) і запускає зовнішні команди — НЕ read-only.
44
38
  - Перед будь-якою мутацією перевіряє, що `cwd` — ізольований worktree (`assertRunningInWorktree`), інакше кидає виняток.
45
- - Падіння одного пакета/крейта в ізольованому виклику раннера не втрачає прогрес по інших записах.
39
+ - Виняток усередині одного провайдера (bump/diff/команда) не зупиняє інших — фіксується в `error` запису екосистеми й у звіті; `ok` результату тоді false.
40
+ - Падіння одного пакета в ізольованому виклику раннера не втрачає прогрес по інших записах.
@@ -0,0 +1,42 @@
1
+ ---
2
+ type: JS Module
3
+ title: rust-provider.mjs
4
+ resource: npm/skills/taze/js/rust-provider.mjs
5
+ docgen:
6
+ crc: db99881b
7
+ model: openai-codex/gpt-5.4-mini
8
+ tier: cloud-min
9
+ score: 100
10
+ issues: judge:inaccurate:0.99
11
+ judgeModel: openai-codex/gpt-5.4-mini
12
+ ---
13
+
14
+ ## Огляд
15
+
16
+ Огляд
17
+
18
+ Модуль `rustProvider` об’єднує публічні кроки для роботи з Cargo-залежностями: `findCargoManifests` знаходить `Cargo.toml` у репозиторії, `buildCargoDependencyPrompt` готує запит для major-оновлення Rust-крейта, `backupCargoManifests` створює резервні копії маніфестів, а `cleanupCargoBackups` прибирає їх після завершення.
19
+
20
+ `rustProvider` — вбудований first-party EcosystemProvider Rust/Cargo для taze-оркестратора (контракт `@7n/rules/plugin-api`); до фази 2 живе в ядрі, далі виїде в `@7n/rules-lang-rust`. Модуль виконує реальні файлові операції (бекапи `Cargo.toml`/`Cargo.lock`) і запускає зовнішні команди (`find`, `cargo upgrade`/`cargo update`) — не read-only; сам до мережі не звертається (посилання на crates.io — лише текст промпта для раннера).
21
+
22
+ ## Поведінка
23
+
24
+ - `buildCargoDependencyPrompt` — формує текст завдання для перевірки major-оновлення одного Rust-крейта: підказує звірити breaking changes через сторінку на `https://crates.io/crates/`, changelog або releases репозиторію, знайти зачеплене використання в коді й або нічого не змінювати, або виконати сумісний рефакторинг із підсумком у відповіді.
25
+ - `findCargoManifests` — знаходить `Cargo.toml` у репозиторії, свідомо пропускаючи `node_modules`, `.worktrees` і `target`.
26
+ - `backupCargoManifests` — створює резервні копії всіх знайдених `Cargo.toml` і спільного кореневого `Cargo.lock`.
27
+ - `cleanupCargoBackups` — видаляє резервні копії `Cargo.toml` і `Cargo.lock` після завершення роботи.
28
+ - `rustProvider` — описує вбудованого provider для Rust/Cargo в taze: визначає виявлення маніфестів, перевірку доступності через `cargo-edit`, резервне копіювання, оновлення залежностей, побудову prompt і очищення бекапів.
29
+
30
+ ## Публічний API
31
+
32
+ - buildCargoDependencyPrompt — Формує prompt для одного LLM-циклу оновлення major-версії одного Rust-крейта; оркестратор окремо робить решту кроків без LLM.
33
+ - findCargoManifests — Підбирає всі `Cargo.toml`, оминаючи `node_modules`, `.worktrees` і `target`.
34
+ - backupCargoManifests — Зберігає копії кожного `Cargo.toml` і спільного кореневого `Cargo.lock`; розраховано на один workspace-lock у корені `cwd`, без підтримки кількох незалежних workspace.
35
+ - cleanupCargoBackups — Видаляє тимчасові бекапи `Cargo.toml` і `Cargo.lock` після завершення роботи.
36
+ - rustProvider — Вбудований Rust/Cargo provider для taze-оркестратора у форматі `@7n/rules/plugin-api`; надалі має перейти в `@7n/rules-lang-rust`.
37
+
38
+ ## Гарантії поведінки
39
+
40
+ - Виконує файлові операції (бекапи) і запускає зовнішні команди (`find`, `cargo`) — НЕ read-only.
41
+ - Провал cargo-команди в `bump` кидає помилку з exit-кодом і stderr.
42
+ - Пошук маніфестів свідомо пропускає шляхи: `node_modules`, `.worktrees`, `target`.