@7n/rules 1.14.0 → 1.14.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +1 -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/run-conftest-batch.md +5 -3
  16. package/scripts/lib/ensure-tool.mjs +125 -31
  17. package/scripts/lib/lint-surface/blocking-inventory.mjs +59 -0
  18. package/scripts/lib/lint-surface/docs/blocking-inventory.md +30 -0
  19. package/scripts/lib/lint-surface/docs/index.md +2 -0
  20. package/scripts/lib/lint-surface/docs/policy-lint-adapter.md +2 -3
  21. package/scripts/lib/lint-surface/docs/run-detectors.md +3 -1
  22. package/scripts/lib/lint-surface/docs/scheduler.md +39 -0
  23. package/scripts/lib/lint-surface/docs/types.md +2 -2
  24. package/scripts/lib/lint-surface/policy-lint-adapter.mjs +4 -3
  25. package/scripts/lib/lint-surface/run-detectors.mjs +142 -31
  26. package/scripts/lib/lint-surface/scheduler.mjs +96 -0
  27. package/scripts/lib/lint-surface/types.mjs +3 -0
  28. package/scripts/lib/run-conftest-batch.mjs +16 -11
  29. package/scripts/utils/docs/index.md +1 -0
  30. package/scripts/utils/docs/spawn-async.md +33 -0
  31. package/scripts/utils/spawn-async.mjs +112 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.14.1] - 2026-07-18
4
+
5
+ ### Changed
6
+
7
+ - docs(adr): brainstorm — внутрішній паралелізм lint-оркестратора (#86)
8
+ - Оновлено внутрішній lint-оркестратор.
9
+
3
10
  ## [1.14.0] - 2026-07-18
4
11
 
5
12
  ### Changed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.14.0",
3
+ "version": "1.14.1",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/graphql/tooling/main.mjs
5
5
  docgen:
6
- crc: b165a889
6
+ crc: d341d8e0
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -67,10 +67,10 @@ async function collectGqlHits(root, candidates) {
67
67
  * (умовне правило — без gql цей крок не запускається).
68
68
  * @param {(msg: string) => void} pass success-репортер
69
69
  * @param {(msg: string) => void} fail fail-репортер
70
- * @returns {void}
70
+ * @returns {Promise<void>} результат
71
71
  * @param {string} cwd корінь репозиторію
72
72
  */
73
- function checkExtensionsRecommendation(pass, fail, cwd) {
73
+ async function checkExtensionsRecommendation(pass, fail, cwd) {
74
74
  const pathRel = '.vscode/extensions.json'
75
75
  const pathAbs = join(cwd, pathRel)
76
76
  if (!existsSync(pathAbs)) {
@@ -79,7 +79,7 @@ function checkExtensionsRecommendation(pass, fail, cwd) {
79
79
  )
80
80
  return
81
81
  }
82
- const violations = runConftestBatch({
82
+ const violations = await runConftestBatch({
83
83
  policyDirRel: 'graphql/vscode_extensions',
84
84
  namespace: 'graphql.vscode_extensions',
85
85
  files: [pathAbs]
@@ -123,7 +123,7 @@ export async function lint(ctx) {
123
123
  )
124
124
  }
125
125
 
126
- checkExtensionsRecommendation(pass, fail, root)
126
+ await checkExtensionsRecommendation(pass, fail, root)
127
127
 
128
128
  return reporter.result()
129
129
  }
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/js/eslint/main.mjs
5
5
  docgen:
6
- crc: fa1753b7
6
+ crc: e831a6e8
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.99
@@ -3,16 +3,15 @@
3
3
  * eslint --fix) — окремий T0 `fix-eslint.mjs` (детермінований), не в detector-і.
4
4
  */
5
5
  import { resolve, relative } from 'node:path'
6
- import { spawnSync } from 'node:child_process'
7
6
 
8
7
  import { ESLint } from 'eslint'
9
8
 
10
9
  import { addedLinesByFile } from '../../../scripts/lib/diff-added-lines.mjs'
10
+ import { spawnAsync } from '../../../scripts/utils/spawn-async.mjs'
11
11
  import { WORKTREE_CHECKOUT_GLOBS } from '../../../scripts/utils/walkDir.mjs'
12
12
  import { classifyFindings, eslintResultsToFindings, parseOxlint } from '../lint-findings/main.mjs'
13
13
 
14
14
  const JS_EXT_RE = /\.(?:mjs|cjs|js|jsx|ts|tsx|vue)$/u
15
- const JSON_MAX_BUFFER = 64 * 1024 * 1024
16
15
 
17
16
  /**
18
17
  * @param {string[]} files список шляхів
@@ -23,13 +22,15 @@ export function filterJsFiles(files) {
23
22
  }
24
23
 
25
24
  /**
25
+ * Async (не блокує event loop) — детектор може виконуватись у parallel lane `detectAll()`
26
+ * (ADR 260716-1354).
26
27
  * @param {string[]} args аргументи запуску oxlint
27
28
  * @param {string} cwd робочий каталог
28
- * @returns {{ status: number, stdout: string, stderr: string }} код завершення, stdout і stderr процесу
29
+ * @returns {Promise<{ status: number, stdout: string, stderr: string }>} код завершення, stdout і stderr процесу
29
30
  */
30
- function runOxlintJson(args, cwd) {
31
- const r = spawnSync('bunx', args, { cwd, encoding: 'utf8', maxBuffer: JSON_MAX_BUFFER })
32
- return { status: typeof r.status === 'number' ? r.status : 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
31
+ async function runOxlintJson(args, cwd) {
32
+ const r = await spawnAsync('bunx', args, { cwd })
33
+ return { status: typeof r.exitCode === 'number' ? r.exitCode : 1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
33
34
  }
34
35
 
35
36
  /**
@@ -68,7 +69,7 @@ async function collectFindings(js, cwd) {
68
69
  js === null
69
70
  ? ['oxlint', '--format=json', ...worktreeIgnoreArgs]
70
71
  : ['oxlint', '--format=json', ...worktreeIgnoreArgs, ...js]
71
- const oxRes = runOxlintJson(oxArgs, cwd)
72
+ const oxRes = await runOxlintJson(oxArgs, cwd)
72
73
  const ox = parseOxlint(oxRes.stdout)
73
74
  if (ox === null && oxRes.status !== 0) {
74
75
  // Хвости stdout/stderr — інакше на CI причина крашу (OOM, конфіг, версія) невидима.
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/js-run/runtime/main.mjs
5
5
  docgen:
6
- crc: e50892ad
6
+ crc: 9f11393b
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.99
@@ -45,10 +45,10 @@ function backendPackageHasSrcDir(absPackageRoot) {
45
45
  * @param {string} label префікс `[pkg] `
46
46
  * @param {(msg: string) => void} fail callback для повідомлень про порушення
47
47
  * @param {(msg: string) => void} passFn callback для повідомлень про успішну перевірку
48
- * @returns {void}
48
+ * @returns {Promise<void>} результат
49
49
  * @param {string} cwd корінь репозиторію
50
50
  */
51
- function checkBackendJsconfigWhenSrcPresent(rootDir, absPackageRoot, label, fail, passFn, cwd) {
51
+ async function checkBackendJsconfigWhenSrcPresent(rootDir, absPackageRoot, label, fail, passFn, cwd) {
52
52
  if (!backendPackageHasSrcDir(absPackageRoot)) return
53
53
 
54
54
  const jcPath = join(cwd, rootDir, 'jsconfig.json')
@@ -59,7 +59,7 @@ function checkBackendJsconfigWhenSrcPresent(rootDir, absPackageRoot, label, fail
59
59
  )
60
60
  return
61
61
  }
62
- const violations = runConftestBatch({
62
+ const violations = await runConftestBatch({
63
63
  policyDirRel: 'js-run/jsconfig',
64
64
  namespace: 'js_run.jsconfig',
65
65
  files: [jcPath]
@@ -3201,7 +3201,7 @@ async function validateHasuraHttpRouteCanon(root, yamlFiles, fail) {
3201
3201
  }
3202
3202
  }
3203
3203
  if (pairedFiles.size === 0) return
3204
- const violations = runConftestBatch({
3204
+ const violations = await runConftestBatch({
3205
3205
  policyDirRel: 'k8s/hasura_httproute',
3206
3206
  namespace: 'k8s.hasura_httproute',
3207
3207
  files: [...pairedFiles]
@@ -3626,7 +3626,7 @@ async function validateHasuraConfigMapRemoteSchemaPermissions(root, yamlFilesAbs
3626
3626
  }
3627
3627
  }
3628
3628
  if (paired.length === 0) return
3629
- const violations = runConftestBatch({
3629
+ const violations = await runConftestBatch({
3630
3630
  policyDirRel: 'k8s/hasura_configmap',
3631
3631
  namespace: 'k8s.hasura_configmap',
3632
3632
  files: paired
@@ -6604,9 +6604,9 @@ function k8sRegoFixHint(ns, file, message) {
6604
6604
  * @param {string} root корінь репозиторію.
6605
6605
  * @param {string[]} yamlFiles абсолютні шляхи *.yaml під `…/k8s/`.
6606
6606
  * @param {(msg: string, hint?: unknown) => void} fail callback реєстрації порушення.
6607
- * @returns {void}
6607
+ * @returns {Promise<void>} результат
6608
6608
  */
6609
- function runAllK8sRego(root, yamlFiles, fail) {
6609
+ async function runAllK8sRego(root, yamlFiles, fail) {
6610
6610
  const relOf = abs => relative(root, abs).replaceAll('\\', '/') || abs
6611
6611
 
6612
6612
  const allYaml = yamlFiles
@@ -6645,7 +6645,7 @@ function runAllK8sRego(root, yamlFiles, fail) {
6645
6645
 
6646
6646
  for (const t of targets) {
6647
6647
  if (t.files.length === 0) continue
6648
- const violations = runConftestBatch({
6648
+ const violations = await runConftestBatch({
6649
6649
  policyDirRel: t.dir,
6650
6650
  namespace: t.ns,
6651
6651
  files: t.files,
@@ -6707,7 +6707,7 @@ export async function lint(ctx) {
6707
6707
  // Plan B: пер-документні структурні правила — у rego-полісі `npm/policy/k8s/*`,
6708
6708
  // викликаємо одним батчем на namespace через runConftestBatch. JS нижче робить
6709
6709
  // лише cross-file orchestration, modeline та FS-existence перевірки.
6710
- runAllK8sRego(root, yamlFiles, fail)
6710
+ await runAllK8sRego(root, yamlFiles, fail)
6711
6711
  pass(`Rego-полісі (npm/policy/k8s/*) виконано на ${yamlFiles.length} файл(ах)`)
6712
6712
 
6713
6713
  for (const abs of yamlFiles) {
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/nginx-default-tpl/template/main.mjs
5
5
  docgen:
6
- crc: e45b3d5f
6
+ crc: 1197df15
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.99
@@ -378,12 +378,12 @@ async function checkDockerfiles(root, ignorePaths, passFn, failFn) {
378
378
  * @param {(msg: string) => void} passFn callback при успішній перевірці
379
379
  * @param {(msg: string) => void} failFn callback при помилці
380
380
  * @param {string} cwd корінь репозиторію
381
- * @returns {void}
381
+ * @returns {Promise<void>} результат
382
382
  */
383
- function checkVscodeNginx(passFn, failFn, cwd) {
383
+ async function checkVscodeNginx(passFn, failFn, cwd) {
384
384
  const extPath = join(cwd, '.vscode/extensions.json')
385
385
  if (existsSync(extPath)) {
386
- const violations = runConftestBatch({
386
+ const violations = await runConftestBatch({
387
387
  policyDirRel: 'nginx-default-tpl/vscode_extensions',
388
388
  namespace: 'nginx_default_tpl.vscode_extensions',
389
389
  files: [extPath]
@@ -402,7 +402,7 @@ function checkVscodeNginx(passFn, failFn, cwd) {
402
402
  failFn('Очікується .vscode/settings.json з форматером nginx і formatOnSave (див. nginx-default-tpl.mdc)')
403
403
  return
404
404
  }
405
- const violations = runConftestBatch({
405
+ const violations = await runConftestBatch({
406
406
  policyDirRel: 'nginx-default-tpl/vscode_settings',
407
407
  namespace: 'nginx_default_tpl.vscode_settings',
408
408
  files: [setPath]
@@ -499,7 +499,7 @@ export async function lint(ctx) {
499
499
  }
500
500
 
501
501
  await checkDockerfiles(root, ignorePaths, pass, fail)
502
- checkVscodeNginx(pass, fail, root)
502
+ await checkVscodeNginx(pass, fail, root)
503
503
 
504
504
  return reporter.result()
505
505
  }
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: main.mjs
4
4
  resource: npm/rules/tauri/tooling/main.mjs
5
5
  docgen:
6
- crc: 7c892c9b
6
+ crc: 4b6ecb02
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 95
9
9
  issues: anchor-miss:(tauri.mdc),judge:inaccurate:0.97
@@ -79,7 +79,7 @@ export async function lint(ctx) {
79
79
  fail(`${extPath} не існує — створи з recommendations "tauri-apps.tauri-vscode" (tauri.mdc)`)
80
80
  return reporter.result()
81
81
  }
82
- const violations = runConftestBatch({
82
+ const violations = await runConftestBatch({
83
83
  policyDirRel: 'tauri/vscode_extensions',
84
84
  namespace: 'tauri.vscode_extensions',
85
85
  files: [extPath]
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: ensure-tool.mjs
4
4
  resource: npm/scripts/lib/ensure-tool.mjs
5
5
  docgen:
6
- crc: 42b0cd01
6
+ crc: b1b054d0
7
7
  ---
8
8
 
9
9
  Модуль `ensure-tool.mjs` — єдина точка резолву зовнішніх CLI-залежностей пакета `@7n/rules`. Він гарантує, що потрібний бінарник (`hk`, `conftest`, `shellcheck`, `actionlint`, `dotenv-linter`, `opa`, `regal`, `hadolint`, `kubeconform`, `kubescape`) доступний у системі, виконуючи послідовний пошук:
@@ -15,14 +15,17 @@ docgen:
15
15
 
16
16
  Така архітектура усуває дублювання install-логіки в кожному `lint.mjs` / `fix.mjs`: щоб додати нову зовнішню утиліту, достатньо одного запису в реєстрі `TOOLS`. Додатково модуль експортує `ensureHkInstall`, який реєструє git pre-commit hook через `hk install` (пропускається в CI).
17
17
 
18
- Файл написаний для Node.js (ESM), використовує лише стандартну бібліотеку та один локальний хелпер `resolveCmd`.
18
+ Поруч із синхронною `ensureTool` (публічний API пакета, сигнатура не змінюється) модуль експортує async-варіант `ensureToolAsync(toolId)` для parallel lane `detectAll()` (ADR 260716-1354-внутрішній-паралелізм-lint-оркестратора): конкурентні виклики того самого `toolId` в одному Node-процесі колапсують в один install (in-process single-flight), а auto-install крок додатково серіалізується між процесами через `withLock` (ключ `ensure-tool/<toolId>`) — паралельні Node-процеси (різні CI-shard-и, кілька агентів) не тягнуть той самий бінарник конкурентно. Завантажений архів завжди пишеться в унікальний per-call temp-каталог і публікується атомарним `renameSync` під фіксованим flat-іменем `<toolId>` — цей hardened install-крок спільний для sync і async шляхів.
19
+
20
+ Файл написаний для Node.js (ESM), використовує лише стандартну бібліотеку, локальний хелпер `resolveCmd` і `withLock` (`../utils/with-lock.mjs`) для міжпроцесної серіалізації async-install-кроку.
19
21
 
20
22
  ## Експорти / API
21
23
 
22
- | Експорт | Тип | Призначення |
23
- | ------------------------ | ---------- | -------------------------------------------------------------------------------------------------------------- |
24
- | `ensureTool(toolId)` | `function` | Резолвить і за потреби встановлює зовнішній CLI. Повертає абсолютний шлях до бінарника або кидає `Error`. |
25
- | `ensureHkInstall(hkBin)` | `function` | Виконує `hk install` для реєстрації git pre-commit hook. Жодного return value; на помилку лише `console.warn`. |
24
+ | Експорт | Тип | Призначення |
25
+ | --------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
26
+ | `ensureTool(toolId)` | `function` | Резолвить і за потреби встановлює зовнішній CLI (sync). Повертає абсолютний шлях до бінарника або кидає `Error`. |
27
+ | `ensureToolAsync(toolId)` | `function` | Async-варіант для parallel lane `detectAll()`: single-flight (in-process) + `withLock` (cross-process) навколо auto-install кроку. Повертає `Promise<string>`. |
28
+ | `ensureHkInstall(hkBin)` | `function` | Виконує `hk install` для реєстрації git pre-commit hook. Жодного return value; на помилку лише `console.warn`. |
26
29
 
27
30
  Внутрішні (не експортуються, але формують контракт модуля):
28
31
 
@@ -81,19 +84,18 @@ docgen:
81
84
  1. Резолвить `curl` та `tar` у PATH; за відсутності — кидає `Error`.
82
85
  2. Через `fetchLatestVersion` отримує актуальну версію.
83
86
  3. Формує назву asset через `entry.asset(ver)` і URL `https://github.com/<github>/releases/download/v<ver>/<asset>`.
84
- 4. Створює `cacheDir` (`mkdirSync` з `recursive: true`).
85
- 5. Завантажує asset через `curl -sSL -o <archivePath> <downloadUrl>`.
86
- 6. Якщо `entry.archive === false` — перейменовує завантажений файл у `<cacheDir>/<toolId>`, виставляє права `0o755` і повертає шлях.
87
- 7. Інакше викликає `tar` із прапорцем `-xJf` (для `.tar.xz`) або `-xzf` (для `.tar.gz`) для розпакування в `cacheDir`.
88
- 8. Визначає реальний шлях до бінарника через `entry.binFinder(ver)` або просто `toolId`. Перевіряє існування файлу.
89
- 9. Опціонально видаляє завантажений архів через `rm` (м’яко: якщо `rm` не знайдено, продовжує).
87
+ 4. Створює `cacheDir` (`mkdirSync` з `recursive: true`) і унікальний per-call temp-каталог усередині нього (`mkdtempSync(join(cacheDir, '.tmp-<toolId>-'))`) — той самий filesystem гарантує, що фінальний `renameSync` не впаде з `EXDEV`.
88
+ 5. Завантажує asset у temp-каталог через `curl -sSL -o <tmpDir>/<asset> <downloadUrl>`.
89
+ 6. Якщо `entry.archive === false` — `chmodSync` завантаженого файлу (`0o755`) і атомарний `renameSync` у `<cacheDir>/<toolId>`.
90
+ 7. Інакше викликає `tar` із прапорцем `-xJf` (для `.tar.xz`) або `-xzf` (для `.tar.gz`) для розпакування в temp-каталог, знаходить реальний шлях бінарника через `entry.binFinder(ver)` або просто `toolId`, перевіряє його існування — і так само атомарним `renameSync` публікує його у `<cacheDir>/<toolId>` (flat-ім'я, незалежно від вкладеної структури архіву).
91
+ 8. У `finally` прибирає весь temp-каталог (`rmSync(tmpDir, { recursive: true, force: true })`) і архів, і проміжні файли розпакування зникають одним викликом.
90
92
  - **Помилки:**
91
93
  - `curl не знайдено в PATH — потрібен для завантаження <toolId>`.
92
94
  - `tar не знайдено в PATH — потрібен для встановлення <toolId>`.
93
95
  - `Завантаження <toolId> не вдалось: ...` / `curl exit <status> при завантаженні <toolId>: ...`.
94
96
  - `tar failed for <toolId>: ...` / `tar exit <status> для <toolId>: ...`.
95
- - `Бінарник <toolId> не знайдено після розпакування: <binPath>`.
96
- - **Side effects:** мережа, файлова система (створення/перейменування файлів, chmod, видалення архіву).
97
+ - `Бінарник <toolId> не знайдено після розпакування: <extractedBin>`.
98
+ - **Side effects:** мережа, файлова система (унікальний temp-каталог, атомарна публікація, chmod, очищення temp).
97
99
 
98
100
  ### `installViaBrew(toolId, entry)`
99
101
 
@@ -149,7 +151,7 @@ docgen:
149
151
  - **Послідовність резолву:**
150
152
  1. **Валідація** — якщо `TOOLS[toolId]` відсутній, кидає `ensureTool: невідомий тул '<toolId>'`.
151
153
  2. **PATH** — `resolveCmd(toolId)`; якщо знайдено — повертає одразу.
152
- 3. **Кеш** — `join(getCacheDir(), toolId)`; якщо файл існує — повертає його шлях. Зауваження: перевірка спрощена і не враховує `entry.binFinder` (для cached binaries `installFromGithub` уже клав фінальний бінарник у відоме місце або кеш просто не містить такого файлу у такому разі переходимо до install).
154
+ 3. **Кеш** — `join(getCacheDir(), toolId)`; якщо файл існує — повертає його шлях. Install завжди публікує бінарник під цим самим flat-іменем (атомарний `renameSync`, незалежно від вкладеної структури архіву напр. `shellcheck` розпаковується у `shellcheck-v<ver>/shellcheck`), тож ця перевірка коректно бачить кеш і після `entry.binFinder`-архівів.
153
155
  4. **Авто-install** — якщо змінна середовища `N_CURSOR_NO_AUTO_INSTALL` не виставлена, викликає `autoInstall(toolId, entry, cacheDir)`.
154
156
  5. **Hard-fail** — кидає `Error(buildHint(toolId, entry))`.
155
157
  - **Помилки:** будь-яка з помилок `autoInstall` / `installFrom*` піднімається вгору; додатково — `невідомий тул` та `❌ ... не знайдено в PATH`.
@@ -209,13 +211,13 @@ docgen:
209
211
  - `fetchLatestVersion('koalaman/shellcheck', curl)` → наприклад `0.10.0`.
210
212
  - asset name = `shellcheck-v0.10.0.linux.x86_64.tar.xz`.
211
213
  - URL = `https://github.com/koalaman/shellcheck/releases/download/v0.10.0/<asset>`.
212
- - `mkdirSync(cacheDir, { recursive: true })`.
213
- - `curl -sSL -o <cacheDir>/<asset> <url>`.
214
- - `tar -xJf <asset> -C <cacheDir>` (бо `.tar.xz`).
215
- - `binFinder('0.10.0')` → `shellcheck-v0.10.0/shellcheck`.
216
- - Перевірка `existsSync(<cacheDir>/shellcheck-v0.10.0/shellcheck)`.
217
- - `rm <archivePath>`.
218
- - Повертає `<cacheDir>/shellcheck-v0.10.0/shellcheck`.
214
+ - `mkdirSync(cacheDir, { recursive: true })` і унікальний `tmpDir = mkdtempSync(join(cacheDir, '.tmp-shellcheck-'))`.
215
+ - `curl -sSL -o <tmpDir>/<asset> <url>`.
216
+ - `tar -xJf <asset> -C <tmpDir>` (бо `.tar.xz`).
217
+ - `binFinder('0.10.0')` → `<tmpDir>/shellcheck-v0.10.0/shellcheck`; перевірка `existsSync`.
218
+ - Атомарний `renameSync(<tmpDir>/shellcheck-v0.10.0/shellcheck, <cacheDir>/shellcheck)` — публікація під flat-іменем.
219
+ - `rmSync(tmpDir, { recursive: true, force: true })` у `finally`.
220
+ - Повертає `<cacheDir>/shellcheck`.
219
221
  7. Викликач отримує абсолютний шлях і запускає `spawnSync(bin, [...args])`.
220
222
 
221
223
  ### Сценарій блокування авто-installу
@@ -3,20 +3,22 @@ type: JS Module
3
3
  title: run-conftest-batch.mjs
4
4
  resource: npm/scripts/lib/run-conftest-batch.mjs
5
5
  docgen:
6
- crc: 1ae29233
6
+ crc: 39e76cec
7
7
  ---
8
8
 
9
9
  Файл запускає `conftest test` на заданому списку файлів, виявляючи порушення правил, визначених у Rego-полісіях. Він використовується для автоматизованої перевірки конфігураційних файлів на відповідність заданим вимогам. Результати перевірки повертаються у структурованому вигляді, що дозволяє інтегрувати результати в інші процеси валідації.
10
10
 
11
+ `runConftestBatch` — **async** (ADR 260716-1354-внутрішній-паралелізм-lint-оркестратора): `conftest` запускається через non-blocking `spawnAsync` (не `spawnSync`), а бінарник резолвиться через `ensureToolAsync('conftest')` — це дозволяє функції брати участь у parallel lane `detectAll()` замість того, щоб блокувати event loop цілком. Приймає опційні `signal` (`AbortSignal`) і `timeoutMs`, обидва прокидаються у `spawnAsync`.
12
+
11
13
  ## Поведінка
12
14
 
13
15
  buildConftestArgs: Будує аргументи командного рядка для запуску `conftest test`, враховуючи список файлів, namespace та додаткові аргументи.
14
- runConftestBatch: Запускає `conftest test` для заданого списку файлів, повертає масив порушень у форматі JSON, якщо `conftest` успішно завершився, і кидає виняток, якщо `conftest` не знайдено або завершився з помилкою. Створює тимчасову директорію для збереження даних шаблону, якщо передано `templateData`.
16
+ runConftestBatch: Асинхронно запускає `conftest test` для заданого списку файлів, повертає `Promise` з масивом порушень у форматі JSON, якщо `conftest` успішно завершився, і кидає виняток (реджектить), якщо `conftest` не знайдено або завершився з помилкою (включно з `null` exit-кодом — процес вбито через `timeoutMs`/`signal`). Створює тимчасову директорію для збереження даних шаблону, якщо передано `templateData`.
15
17
 
16
18
  ## Публічний API
17
19
 
18
20
  - buildConftestArgs: Створює аргументи для тесту conftest. Витягнуто для зручності тестування. Зберігає поточну структуру аргументів (файли перед `-p`, `--output json` та `--no-color` для читабельного виводу) і вставляє `--data` після `--namespace`, якщо він заданий.
19
- - runConftestBatch: Запускає `conftest test` для всіх файлів з одного процесу та повертає масив помилок. Якщо `files` порожній, повертає порожній масив. Якщо `conftest` не знайдено в системному шляху та автоматична установка не вдалася, виникає помилка.
21
+ - runConftestBatch: Асинхронно запускає `conftest test` для всіх файлів з одного процесу та повертає `Promise` з масивом помилок. Якщо `files` порожній, резолвиться порожнім масивом без спавну. Якщо `conftest` не знайдено в системному шляху та автоматична установка не вдалася, `Promise` реджектиться.
20
22
 
21
23
  ## Гарантії поведінки
22
24
 
@@ -6,16 +6,24 @@
6
6
  *
7
7
  * Per-platform matrix: macOS → brew, Windows → scoop (fallback: GitHub Release), Linux → GitHub Release binary.
8
8
  * Бінарники кешуються у `~/.cache/@7n/rules/bin/` (Linux/Mac), `%LOCALAPPDATA%\@7n\cursor\bin\` (Win).
9
+ * Download завжди пишеться в унікальний per-call temp-каталог і публікується атомарним `renameSync` —
10
+ * паралельні install того самого тула (різні процеси/промиси) не тупцюють по спільному archive-шляху.
11
+ *
12
+ * `ensureTool` лишається синхронним — публічний API пакету (`@7n/rules/scripts/lib/ensure-tool.mjs`,
13
+ * реально споживається зовнішнім `plugins/ci-github`), сигнатуру не міняємо. `ensureToolAsync(toolId)` —
14
+ * async-варіант для parallel lane `detectAll()`: внутрішньопроцесний single-flight + міжпроцесний
15
+ * `withLock` навколо auto-install кроку (`docs/adr/260716-1354-…`).
9
16
  *
10
17
  * `ensureHkInstall(hkBin)` — реєструє git pre-commit hook через `hk install`; пропускається в CI.
11
18
  */
12
19
  import { spawnSync } from 'node:child_process'
13
- import { chmodSync, existsSync, mkdirSync, renameSync } from 'node:fs'
20
+ import { chmodSync, existsSync, mkdirSync, mkdtempSync, renameSync, rmSync } from 'node:fs'
14
21
  import { homedir } from 'node:os'
15
22
  import { join } from 'node:path'
16
23
  import { arch, env, platform } from 'node:process'
17
24
 
18
25
  import { resolveCmd } from '../utils/resolve-cmd.mjs'
26
+ import { withLock } from '../utils/with-lock.mjs'
19
27
 
20
28
  /** Префікс `v` у git-тегу релізу (`v1.2.3` → `1.2.3`). */
21
29
  const TAG_V_PREFIX_RE = /^v/
@@ -189,39 +197,53 @@ function installFromGithub(toolId, entry, cacheDir) {
189
197
  const downloadUrl = `https://github.com/${entry.github}/releases/download/v${ver}/${assetName}`
190
198
 
191
199
  mkdirSync(cacheDir, { recursive: true })
192
- const archivePath = join(cacheDir, assetName)
193
-
194
- const dlResult = spawnSync(curlBin, ['-sSL', '-o', archivePath, downloadUrl], { encoding: 'utf8' })
195
- if (dlResult.error) throw new Error(`Завантаження ${toolId} не вдалось: ${dlResult.error.message}`)
196
- if (dlResult.status !== 0)
197
- throw new Error(`curl exit ${dlResult.status} при завантаженні ${toolId}: ${(dlResult.stderr ?? '').slice(0, 300)}`)
198
-
199
- // Сирий бінарник (archive: false) — завантажений файл і є бінарником: перейменовуємо у <toolId> + chmod.
200
- if (entry.archive === false) {
201
- const binPath = join(cacheDir, toolId)
202
- renameSync(archivePath, binPath)
203
- chmodSync(binPath, 0o755)
204
- return binPath
205
- }
200
+ // Унікальний per-call temp-каталог у тому ж cacheDir (той самий filesystem — атомарний renameSync
201
+ // наприкінці не впаде з EXDEV). Паралельні install-и того самого тула пишуть у різні temp-каталоги,
202
+ // не конфліктуючи один з одним; під фіксованим `<toolId>`-іменем публікується лише готовий бінарник.
203
+ const tmpDir = mkdtempSync(join(cacheDir, `.tmp-${toolId}-`))
204
+ try {
205
+ const archivePath = join(tmpDir, assetName)
206
206
 
207
- // .tar.xz потребує -J замість -z
208
- const isXz = assetName.endsWith('.tar.xz')
209
- const tarFlags = isXz ? ['-xJf'] : ['-xzf']
210
- const extractResult = spawnSync(tarBin, [...tarFlags, archivePath, '-C', cacheDir], { encoding: 'utf8' })
211
- if (extractResult.error) throw new Error(`tar failed for ${toolId}: ${extractResult.error.message}`)
212
- if (extractResult.status !== 0)
213
- throw new Error(`tar exit ${extractResult.status} для ${toolId}: ${(extractResult.stderr ?? '').slice(0, 300)}`)
214
-
215
- const binRelPath = entry.binFinder ? entry.binFinder(ver) : toolId
216
- const binPath = join(cacheDir, binRelPath)
217
- if (!existsSync(binPath)) {
218
- throw new Error(`Бінарник ${toolId} не знайдено після розпакування: ${binPath}`)
219
- }
207
+ const dlResult = spawnSync(curlBin, ['-sSL', '-o', archivePath, downloadUrl], { encoding: 'utf8' })
208
+ if (dlResult.error) throw new Error(`Завантаження ${toolId} не вдалось: ${dlResult.error.message}`)
209
+ if (dlResult.status !== 0) {
210
+ throw new Error(
211
+ `curl exit ${dlResult.status} при завантаженні ${toolId}: ${(dlResult.stderr ?? '').slice(0, 300)}`
212
+ )
213
+ }
214
+
215
+ const publishedBin = join(cacheDir, toolId)
216
+
217
+ // Сирий бінарник (archive: false) — завантажений файл і є бінарником: chmod + атомарна публікація.
218
+ if (entry.archive === false) {
219
+ chmodSync(archivePath, 0o755)
220
+ renameSync(archivePath, publishedBin)
221
+ return publishedBin
222
+ }
223
+
224
+ // .tar.xz потребує -J замість -z
225
+ const isXz = assetName.endsWith('.tar.xz')
226
+ const tarFlags = isXz ? ['-xJf'] : ['-xzf']
227
+ const extractResult = spawnSync(tarBin, [...tarFlags, archivePath, '-C', tmpDir], { encoding: 'utf8' })
228
+ if (extractResult.error) throw new Error(`tar failed for ${toolId}: ${extractResult.error.message}`)
229
+ if (extractResult.status !== 0) {
230
+ throw new Error(`tar exit ${extractResult.status} для ${toolId}: ${(extractResult.stderr ?? '').slice(0, 300)}`)
231
+ }
220
232
 
221
- const rmBin = resolveCmd('rm')
222
- if (rmBin) spawnSync(rmBin, [archivePath])
233
+ const binRelPath = entry.binFinder ? entry.binFinder(ver) : toolId
234
+ const extractedBin = join(tmpDir, binRelPath)
235
+ if (!existsSync(extractedBin)) {
236
+ throw new Error(`Бінарник ${toolId} не знайдено після розпакування: ${extractedBin}`)
237
+ }
223
238
 
224
- return binPath
239
+ // Атомарна публікація під фіксованим flat-іменем — незалежно від вкладеної структури архіву
240
+ // (напр. shellcheck розпаковується у `shellcheck-v<ver>/shellcheck`), щоб наступний виклик
241
+ // `ensureTool` бачив кеш за тим самим шляхом, яким його перевіряє (`join(cacheDir, toolId)`).
242
+ renameSync(extractedBin, publishedBin)
243
+ return publishedBin
244
+ } finally {
245
+ rmSync(tmpDir, { recursive: true, force: true })
246
+ }
225
247
  }
226
248
 
227
249
  /**
@@ -334,6 +356,78 @@ export function ensureTool(toolId) {
334
356
  throw new Error(buildHint(toolId, entry))
335
357
  }
336
358
 
359
+ /** Внутрішньопроцесний single-flight: конкурентні `ensureToolAsync(toolId)` в одному Node-процесі колапсують в один install. */
360
+ const inFlightInstalls = new Map()
361
+
362
+ /**
363
+ * Обгортає `autoInstall` міжпроцесним `withLock` — паралельні Node-процеси (різні CI-shard-и,
364
+ * кілька агентів на тій самій машині) чекають у черзі замість конкурентного запису в спільний
365
+ * cache/archive-шлях. Fingerprint-дедуп локу вимкнено (`getFingerprint: () => null`) — той
366
+ * механізм призначений для повторних CLI-команд на тому самому git-дереві, тут важлива лише
367
+ * взаємовиключність; після взяття локу перевіряємо кеш повторно (інший процес міг встановити,
368
+ * поки ми чекали).
369
+ * @param {string} toolId ключ у реєстрі TOOLS
370
+ * @param {ToolEntry} entry опис тула
371
+ * @param {string} cacheDir каталог кешу
372
+ * @returns {Promise<string>} абсолютний шлях до бінарника
373
+ */
374
+ async function installWithCrossProcessLock(toolId, entry, cacheDir) {
375
+ let resultPath = null
376
+ await withLock(
377
+ `ensure-tool/${toolId}`,
378
+ () => {
379
+ const cachedBin = join(cacheDir, toolId)
380
+ resultPath = existsSync(cachedBin) ? cachedBin : autoInstall(toolId, entry, cacheDir)
381
+ return 0
382
+ },
383
+ { onWaitTimeout: 'fail', getFingerprint: () => null }
384
+ )
385
+ return resultPath
386
+ }
387
+
388
+ /**
389
+ * Async-варіант `ensureTool` для parallel lane `detectAll()` (ADR 260716-1354). `ensureTool`
390
+ * (sync) лишається незміненою — публічний API пакета; ця функція існує окремо, не заміняє її.
391
+ *
392
+ * Fast-paths (PATH, уже закешований бінарник) — ідентичні sync-версії. Auto-install — єдина
393
+ * гілка, що реально потребує async: обгорнута internal single-flight (`inFlightInstalls`) і
394
+ * cross-process `withLock`, щоб паралельні виклики того самого `toolId` (в одному процесі чи
395
+ * кількох) не тягнули install конкурентно.
396
+ * @param {string} toolId ключ у реєстрі TOOLS (`'hk'`, `'conftest'`, `'shellcheck'`, `'actionlint'`, `'dotenv-linter'`, `'opa'`, `'regal'`, `'hadolint'`, `'kubeconform'`, `'kubescape'`)
397
+ * @returns {Promise<string>} абсолютний шлях до бінарника
398
+ */
399
+ export async function ensureToolAsync(toolId) {
400
+ const entry = TOOLS[toolId]
401
+ if (!entry) throw new Error(`ensureTool: невідомий тул '${toolId}'`)
402
+
403
+ // 1. PATH
404
+ const fromPath = resolveCmd(toolId)
405
+ if (fromPath) return fromPath
406
+
407
+ // 2. Кеш
408
+ const cacheDir = getCacheDir()
409
+ const cachedBin = join(cacheDir, toolId)
410
+ if (existsSync(cachedBin)) return cachedBin
411
+
412
+ // 3. Hard-fail (opt-out) — до single-flight, щоб не ставити зайвий запис у Map даремно
413
+ if (env['N_CURSOR_NO_AUTO_INSTALL']) throw new Error(buildHint(toolId, entry))
414
+
415
+ // 4. Авто-install: single-flight (in-process) + withLock (cross-process)
416
+ const inFlight = inFlightInstalls.get(toolId)
417
+ if (inFlight) return inFlight
418
+
419
+ const installPromise = installWithCrossProcessLock(toolId, entry, cacheDir)
420
+ inFlightInstalls.set(toolId, installPromise)
421
+ try {
422
+ // Єдиний реальний await у функції: коли installPromise усталиться (для ЦЬОГО, ініціюючого
423
+ // виклику — конкурентні виклики вище просто повернули той самий inFlight), приберемо запис
424
+ // з Map рівно один раз, незалежно від того, скільки викликів чекало на той самий проміс.
425
+ return await installPromise
426
+ } finally {
427
+ inFlightInstalls.delete(toolId)
428
+ }
429
+ }
430
+
337
431
  /**
338
432
  * Реєструє git pre-commit hook через `hk install`.
339
433
  * Пропускається в CI (`process.env.CI`). Попереджає (не кидає) на помилку.