@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
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Інвентар concern-ів, чий detector ще НЕ доведений на async non-blocking шлях
3
+ * (ADR 260716-1354-внутрішній-паралелізм-lint-оркестратора). `detectAll()` виконує
4
+ * ці concern-и у serial lane (строго послідовно, ніколи не перекриваючись самі із
5
+ * собою) — не заявляємо паралелізм там, де detector все ще звертається до `spawnSync`/
6
+ * `execSync` (прямо або через спільний helper), бо це блокує event loop цілком і
7
+ * зробило б паралельний пул ілюзорним.
8
+ *
9
+ * Нова міграція (наступний shared helper на `spawnAsync`, за протоколом
10
+ * `runConftestBatch`/`runOxlintJson`): переведи helper → онови caller-и (`await`) →
11
+ * прибери відповідний запис звідси → розшир `docs/blocking-inventory-guard.test.mjs`
12
+ * (guard-тест сам перевірить, що жоден із них більше не викликає `spawnSync`/`execSync`).
13
+ */
14
+
15
+ /**
16
+ * Concern-и з прямим `spawnSync`/`execSync` у власному `main.mjs` — кожен спавнить свій
17
+ * зовнішній тул напряму, ще не через `spawnAsync`.
18
+ */
19
+ const DIRECT_SPAWN_CONCERNS = [
20
+ 'text/run-dotenv-linter',
21
+ 'text/oxfmt',
22
+ 'text/cspell-fix',
23
+ 'text/run-v8r',
24
+ 'text/run-shellcheck',
25
+ 'image-compress/check',
26
+ 'php/phpcs',
27
+ 'php/project',
28
+ 'php/cs_fixer',
29
+ 'js/jscpd_duplicates',
30
+ 'k8s/manifests',
31
+ 'style/lint',
32
+ 'bun/licensee',
33
+ 'python/ruff',
34
+ 'python/project',
35
+ 'python/mypy',
36
+ 'security/scan',
37
+ 'rust/check',
38
+ 'rego/conftest_verify'
39
+ ]
40
+
41
+ /**
42
+ * Concern-и, чий detector сам не викликає `spawnSync`/`execSync`, але делегує спільному
43
+ * helper-у, який його викликає: `docker/lib/docker-hadolint.mjs` (hadolint) і
44
+ * `rego/lib/run-external-tool.mjs` (regal/opa).
45
+ */
46
+ const SHARED_HELPER_CONCERNS = ['docker/lint', 'rego/regal', 'rego/opa_check']
47
+
48
+ /** Повний serial-lane список: `${ruleId}/${concernId}` — 19 прямих + 3 через shared helper = 22. */
49
+ export const SERIAL_LANE_CONCERNS = new Set([...DIRECT_SPAWN_CONCERNS, ...SHARED_HELPER_CONCERNS])
50
+
51
+ /**
52
+ * Чи concern лишається у serial lane `detectAll()` (недоведений non-blocking).
53
+ * @param {string} ruleId id правила
54
+ * @param {string} concernId id concern-а
55
+ * @returns {boolean} true — serial lane; false — parallel-safe
56
+ */
57
+ export function isSerialLane(ruleId, concernId) {
58
+ return SERIAL_LANE_CONCERNS.has(`${ruleId}/${concernId}`)
59
+ }
@@ -0,0 +1,30 @@
1
+ ---
2
+ type: JS Module
3
+ title: blocking-inventory.mjs
4
+ resource: npm/scripts/lib/lint-surface/blocking-inventory.mjs
5
+ docgen:
6
+ crc: 2e0e8624
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
+ Інвентар concern-ів для `ADR 260716-1354-внутрішній-паралелізм-lint-оркестратора`, чиї detector-и ще не доведені до async non-blocking шляху. `SERIAL_LANE_CONCERNS` і `isSerialLane` фіксують, що `detectAll` виконує ці concern-и в serial lane — строго послідовно, без перекриття між собою. Тут не можна заявляти паралелізм для detector-ів, які ще викликають `spawnSync`/`execSync` напряму або через shared helper, бо це блокує event loop і робить паралельний пул ілюзорним. Міграція йде за інваріантом: helper → caller-и (`await`) → прибрати відповідний запис звідси → розширити `docs/blocking-inventory-guard.test.mjs`; guard-тест має підтвердити, що жоден із цих concern-ів більше не викликає `spawnSync`/`execSync`.
17
+
18
+ ## Поведінка
19
+
20
+ - `SERIAL_LANE_CONCERNS` — перелік concern-ів, які `detectAll` тримає в serial lane, бо їхній detector ще не доведено до non-blocking шляху; сюди свідомо не включені вже переведені на async/`spawnAsync` concern-и.
21
+ - `isSerialLane` — визначає, чи належить вказаний concern до serial lane, щоб оркестратор не оголошував паралельність там, де ще є blocking-виклики напряму або через shared helper.
22
+
23
+ ## Публічний API
24
+
25
+ - SERIAL_LANE_CONCERNS — Повний перелік serial-lane concern-ів у форматі `${ruleId}/${concernId}`: 19 напряму заданих і 3, що додаються через shared helper; разом 22.
26
+ - isSerialLane — Визначає, чи concern має лишатися в serial lane для `detectAll`, коли non-blocking ще не доведено.
27
+
28
+ ## Гарантії поведінки
29
+
30
+ - Read-only: не виконує операцій запису (ФС/БД).
@@ -6,6 +6,7 @@ resource: npm/scripts/lib/lint-surface/
6
6
 
7
7
  | Файл | Тип |
8
8
  | ----------------------------------------------------------- | --------- |
9
+ | [blocking-inventory.mjs](blocking-inventory.md) | JS Module |
9
10
  | [codegen-opa-wrapper.mjs](codegen-opa-wrapper.md) | JS Module |
10
11
  | [collateral-veto.mjs](collateral-veto.md) | JS Module |
11
12
  | [default-worker.mjs](default-worker.md) | JS Module |
@@ -20,6 +21,7 @@ resource: npm/scripts/lib/lint-surface/
20
21
  | [render.mjs](render.md) | JS Module |
21
22
  | [run-detectors.mjs](run-detectors.md) | JS Module |
22
23
  | [run-fix.mjs](run-fix.md) | JS Module |
24
+ | [scheduler.mjs](scheduler.md) | JS Module |
23
25
  | [snapshot.mjs](snapshot.md) | JS Module |
24
26
  | [tier-sampling-bench.mjs](tier-sampling-bench.md) | JS Module |
25
27
  | [tier-sampling-experiment.mjs](tier-sampling-experiment.md) | JS Module |
@@ -3,9 +3,8 @@ type: JS Module
3
3
  title: policy-lint-adapter.mjs
4
4
  resource: npm/scripts/lib/lint-surface/policy-lint-adapter.mjs
5
5
  docgen:
6
- crc: facc5e07
6
+ crc: 1e527329
7
7
  model: openai-codex/gpt-5.5
8
- tier: cloud-avg
9
8
  score: 80
10
9
  issues: internal-name:resolveTargetFiles,internal-name:runConftestBatch,judge:inaccurate:0.97
11
10
  judgeModel: openai-codex/gpt-5.4-mini
@@ -31,7 +30,7 @@ docgen:
31
30
 
32
31
  6. Для кожного target-файлу в template-режимі порівнює фактичний вміст із declarative template-очікуваннями: обовʼязковими фрагментами, заборонами та вимогами на наявність. Кожну невідповідність оформлює як `policy-template-mismatch`.
33
32
 
34
- 7. Для Rego-перевірки запускає policy concern-а через conftest-поверхню та передає доступні template-дані як супровідний контекст, щоб зберегти поведінку існуючої policy-логіки.
33
+ 7. Для Rego-перевірки запускає policy concern-а через conftest-поверхню (`await runConftestBatch`, async), передаючи доступні template-дані як супровідний контекст і прокидаючи `ctx.signal` — щоб зберегти поведінку існуючої policy-логіки й водночас підтримати скасування у parallel lane `detectAll()`.
35
34
 
36
35
  8. Кожен deny-результат Rego оформлює як `policy-deny` із повідомленням policy та, якщо доступно, відносним шляхом до файла-порушника.
37
36
 
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: run-detectors.mjs
4
4
  resource: npm/scripts/lib/lint-surface/run-detectors.mjs
5
5
  docgen:
6
- crc: e8a0ec47
6
+ crc: 631fa41c
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -21,6 +21,8 @@ DEFAULT_RULES_DIR визначає шлях до стандартної дире
21
21
  buildDetectPlan створює впорядкований план прогону лінтера, збираючи всі відповідні концерни та визначаючи область їх сканування.
22
22
  detectAll виконує прохід лінтера у режим детекції, збираючи всі виявлені порушення та повертаючи код виходу.
23
23
 
24
+ **Внутрішній паралелізм (ADR 260716-1354-внутрішній-паралелізм-lint-оркестратора).** `detectAll` читає `N_RULES_LINT_CONCURRENCY` (дефолт `1` — production-паралелізм ще не пройшов benchmark-gates ADR): за замовчуванням план виконується повністю послідовно (`detectPlanSequentially`), спостережувано ідентично до-ADR поведінці. При `concurrency > 1` план ділиться на два лейни через `blocking-inventory.mjs` (`isSerialLane`) і виконується через `scheduler.mjs` (`runPlanConcurrently`, `detectPlanConcurrently`): parallel lane — доведені non-blocking concern-и, bounded pool до `concurrency`; serial lane — решта, строго послідовно. Перший `DetectorError` (будь-який лейн) зупиняє нові старти, `AbortController` сигналізує вже запущеним async-детекторам (`ctx.signal`), а вже завершені concern-и лишаються в результаті — `exitCode 2` пріоритетний над частковими violations. Фінальний масив violations завжди стабільно сортується за `(ruleId, concernId, file, data.line, reason)` — незалежно від порядку завершення (`sortViolations`), навіть при `concurrency=1`.
25
+
24
26
  ## Публічний API
25
27
 
26
28
  * DEFAULT_RULES_DIR — Містить набір стандартних правил для перевірок.
@@ -0,0 +1,39 @@
1
+ ---
2
+ type: JS Module
3
+ title: scheduler.mjs
4
+ resource: npm/scripts/lib/lint-surface/scheduler.mjs
5
+ docgen:
6
+ crc: e909628f
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
+ Bounded two-lane concurrent scheduler для `detectAll`, який активується лише коли `concurrency > 1`; за `concurrency === 1` `detectAll` лишається на повністю послідовному шляху, спостережувано ідентичному до-ADR поведінці, і цей scheduler там навіть не викликається. Має два лейни: **serial lane** з власним sequential runner і mutex-ефектом, де items ніколи не перекриваються самі з собою, та **parallel lane** з bounded pool до `concurrency` слотів. Обидва лейни навмисно виконуються конкурентно один з одним: для serial-lane це лише структурна конкурентність, бо його blocking `spawnSync` все одно заморожує event loop, а parallel-lane отримує реальну вигоду від одночасного очікування кількох `spawnAsync`-викликів. Перша помилка від `runItem` зупиняє нові старти в обох лейнах, `controller.abort` сигналізує вже запущеним async-детекторам, і функція чекає завершення всіх уже стартованих items; кожен `runOne` ловить власну помилку, тож зовнішній `Promise.all` не відхиляється назовні.
17
+
18
+ ## Поведінка
19
+
20
+ 1. `runPlanConcurrently` розділяє вхідний план на два потоки виконання: serial і parallel, щоб паралельно обслуговувати незалежні елементи та не перекривати послідовні між собою.
21
+
22
+ 2. `runPlanConcurrently` запускає serial-потік як суворо послідовний прогін, а parallel-потік — як обмежений пул із максимальною кількістю одночасних активних елементів, визначеною рівнем concurrency.
23
+
24
+ 3. `runPlanConcurrently` збирає результат кожного реально стартованого елемента в порядку завершення, а не в початковому порядку плану.
25
+
26
+ 4. `runPlanConcurrently` сприймає першу помилку як інфраструктурну: зупиняє старт нових елементів у обох потоках і надсилає сигнал скасування вже запущеним асинхронним виконавцям.
27
+
28
+ 5. `runPlanConcurrently` не перериває зовнішнє виконання аварійним винятком: усі вже стартовані елементи дочікуються завершення, а помилки фіксуються в результатах і повертаються разом із першим інфраструктурним збоєм.
29
+
30
+ 6. `runPlanConcurrently` повертає пару: список фактично виконаних елементів із їхнім підсумком або причиною скасування, та першу помилку, якщо вона була; якщо збоїв не було, помилка дорівнює null.
31
+
32
+ ## Публічний API
33
+
34
+ - runPlanConcurrently — зупиняє запуск нових items після кидка, абортує `signal`; `results` містить лише реально запущені items у порядку завершення; `infraError` фіксує першу помилку `runItem` або лишається `null`, якщо все завершилось успішно
35
+
36
+ ## Гарантії поведінки
37
+
38
+ - Read-only: не виконує операцій запису (ФС/БД).
39
+ - Перехоплює помилки і не пропускає винятків назовні (fail-safe).
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: types.mjs
4
4
  resource: npm/scripts/lib/lint-surface/types.mjs
5
5
  docgen:
6
- crc: 4120f280
6
+ crc: 5f1d3eca
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  ---
9
9
 
@@ -13,7 +13,7 @@ docgen:
13
13
 
14
14
  ## Поведінка
15
15
 
16
- 1. Контекст для перевірки визначається через абсолютний шлях до коріня репозиторію, унікальний ідентифікатор правила та ідентифікатор concern-а. Додатково може вказуватися абсолютний шлях до каталогу concern-а або відносний перелік файлів для пофайлового запуску.
16
+ 1. Контекст для перевірки визначається через абсолютний шлях до коріня репозиторію, унікальний ідентифікатор правила та ідентифікатор concern-а. Додатково може вказуватися абсолютний шлях до каталогу concern-а або відносний перелік файлів для пофайлового запуску, а також опційний `AbortSignal` — заповнюється лише в parallel lane `detectAll()` (`N_RULES_LINT_CONCURRENCY>1`), async-детектори прокидають його у свої `spawnAsync`-виклики, щоб перерватись при infrastructure-помилці іншого concern-а.
17
17
  2. Detector створює результат перевірки, який містить перелік виявлених порушень або технічних діагностик.
18
18
  3. Кожне порушення фіксується за унікальною комбінацією `ruleId`, `concernId` та стабільного коду причини. Для порушення вказується описовий текст, а також опціональні метадані, специфічні для concern-а.
19
19
  4. T0-патерн визначає, чи застосовний для групи порушень. Якщо патерн ідемпотентний та самодостатній, він може автоматично ініціювати виправлення без детального аналізу кожного порушення.
@@ -43,7 +43,7 @@ function toRel(abs, cwd) {
43
43
  * @returns {Promise<LintResult>} Уніфікований результат лінту зі списком violations.
44
44
  */
45
45
  export async function evaluatePolicyConcern(ctx, cfg) {
46
- const { cwd, ruleId, concernId } = ctx
46
+ const { cwd, ruleId, concernId, signal } = ctx
47
47
  /** @type {LintViolation[]} */
48
48
  const violations = []
49
49
  /**
@@ -90,13 +90,14 @@ export async function evaluatePolicyConcern(ctx, cfg) {
90
90
  // Rego
91
91
  const namespace = `${ruleId.replaceAll('-', '_')}.${concernId}`
92
92
  const templateData = await resolveConcernTemplateData(cfg.policyDir, { files: cfg.files })
93
- const denies = runConftestBatch({
93
+ const denies = await runConftestBatch({
94
94
  policyDirRel: `${ruleId}/${concernId}`,
95
95
  // Абсолютний шлях теки concern-а — правило може жити поза вбудованим rules/ ядра (плагін).
96
96
  policyDirAbs: cfg.policyDir,
97
97
  namespace,
98
98
  files,
99
- templateData
99
+ templateData,
100
+ signal
100
101
  })
101
102
  for (const d of denies) add('policy-deny', d.message, d.filename ? toRel(d.filename, cwd) : undefined)
102
103
  return { violations }
@@ -10,6 +10,7 @@
10
10
  * @typedef {{ ruleId: string, concern: ConcernMeta }} LintEntry
11
11
  */
12
12
  import { dirname, join } from 'node:path'
13
+ import { env } from 'node:process'
13
14
  import { fileURLToPath } from 'node:url'
14
15
 
15
16
  import picomatch from 'picomatch'
@@ -18,9 +19,11 @@ import { listConcerns } from '../concern-meta.mjs'
18
19
  import { collectChangedFilesSince, resolveChangedBase } from '../changed-files.mjs'
19
20
  import { readNRulesConfigLite, isRuleEnabled } from '../read-n-rules-config-lite.mjs'
20
21
  import { getActiveCapabilities, resolvePlugins } from '../resolve-plugins.mjs'
22
+ import { isSerialLane } from './blocking-inventory.mjs'
21
23
  import { runConcernDetector, DetectorError } from './detect.mjs'
22
24
  import { renderViolations, renderDiagnostics } from './render.mjs'
23
25
  import { createProgressReporter } from './progress.mjs'
26
+ import { runPlanConcurrently } from './scheduler.mjs'
24
27
 
25
28
  // Цей файл: npm/scripts/lib/lint-surface/run-detectors.mjs → PACKAGE_ROOT = npm (4 dirname угору).
26
29
  export const DEFAULT_RULES_DIR = join(dirname(dirname(dirname(dirname(fileURLToPath(import.meta.url))))), 'rules')
@@ -295,6 +298,129 @@ async function buildPlan({ byRule, full, rules, explicitFiles, cwd }) {
295
298
  return buildDeltaPlan(byRule, enabledSet, changed)
296
299
  }
297
300
 
301
+ /**
302
+ * Виконує один item плану: будує ctx, прогонить detector, оновлює progress. Спільний
303
+ * крок для послідовного і конкурентного (`N_RULES_LINT_CONCURRENCY>1`) шляхів `detectAll` —
304
+ * єдина точка, де щось може кинути `DetectorError` (чи іншу помилку), тож caller (sequential
305
+ * for-loop чи `runPlanConcurrently`) вирішує, як саме зупинити прогін.
306
+ * @param {PlanItem} planItem один item плану (`{ entry, files }`).
307
+ * @param {object} runOpts опції прогону.
308
+ * @param {string} runOpts.cwd робоча директорія прогону.
309
+ * @param {boolean} runOpts.verbose докладний лог прогону.
310
+ * @param {import('./progress.mjs').ProgressReporter|null} runOpts.progress reporter прогресу (може бути відсутній).
311
+ * @param {(s: string) => void} runOpts.log функція логування.
312
+ * @param {AbortSignal} [runOpts.signal] сигнал скасування — лише в конкурентному шляху.
313
+ * @returns {Promise<{ entry: LintEntry, violations: LintViolation[] }>} entry і зібрані violations.
314
+ */
315
+ async function runPlanItem({ entry, files }, { cwd, verbose, progress, log, signal }) {
316
+ /** @type {LintContext} */
317
+ const ctx = { cwd, ruleId: entry.ruleId, concernId: entry.concern.name, files, verbose, signal }
318
+ const key = `${entry.ruleId}/${entry.concern.name}`
319
+ progress?.concernStart(key)
320
+ if (verbose) {
321
+ const countStr = files === undefined ? 'весь репо' : `${files.length} файл(ів)`
322
+ log(` 🔍 ${key} [${entry.concern.lint.scope}] → ${countStr}\n`)
323
+ }
324
+ const result = await runConcernDetector(entry.concern, ctx)
325
+ progress?.detectSnapshot(key, result.violations.length)
326
+ progress?.concernDone(key)
327
+ if (verbose && result.diagnostics && result.diagnostics.length > 0) {
328
+ log(renderDiagnostics(result.diagnostics))
329
+ }
330
+ return { entry, violations: result.violations }
331
+ }
332
+
333
+ /**
334
+ * @typedef {{ violations: LintViolation[], ran: LintEntry[], infraMessage: string|null }} PlanRunResult
335
+ */
336
+
337
+ /**
338
+ * Послідовний прохід плану — незмінна поведінка до-ADR 260716-1354 (`N_RULES_LINT_CONCURRENCY<=1`,
339
+ * дефолт). Перший `DetectorError` негайно зупиняє прогін (`infraMessage`); будь-яка інша помилка
340
+ * прокидається далі (несподівана помилка самого раннера, не detector-контракту).
341
+ * @param {PlanItem[]} plan впорядкований план прогону.
342
+ * @param {{ cwd: string, verbose: boolean, progress: import('./progress.mjs').ProgressReporter|null, log: (s: string) => void }} runOpts опції прогону.
343
+ * @returns {Promise<PlanRunResult>} зібрані violations, виконані entries, повідомлення інфра-помилки.
344
+ */
345
+ async function detectPlanSequentially(plan, runOpts) {
346
+ /** @type {LintViolation[]} */
347
+ const violations = []
348
+ /** @type {LintEntry[]} */
349
+ const ran = []
350
+ for (const item of plan) {
351
+ let outcome
352
+ try {
353
+ outcome = await runPlanItem(item, runOpts)
354
+ } catch (error) {
355
+ if (error instanceof DetectorError) return { violations, ran, infraMessage: error.message }
356
+ throw error
357
+ }
358
+ ran.push(outcome.entry)
359
+ violations.push(...outcome.violations)
360
+ }
361
+ return { violations, ran, infraMessage: null }
362
+ }
363
+
364
+ /**
365
+ * Bounded two-lane прохід плану (`N_RULES_LINT_CONCURRENCY>1`, experimental — ADR 260716-1354).
366
+ * Parallel lane — concern-и, доведені non-blocking (`blocking-inventory.mjs`), bounded pool до
367
+ * `concurrency`; serial lane — решта, строго послідовно. Перший `DetectorError` зупиняє нові
368
+ * старти в обох лейнах (`scheduler.mjs`); уже завершені concern-и лишаються в результаті.
369
+ * @param {PlanItem[]} plan впорядкований план прогону.
370
+ * @param {{ cwd: string, verbose: boolean, progress: import('./progress.mjs').ProgressReporter|null, log: (s: string) => void, concurrency: number }} runOpts опції прогону.
371
+ * @returns {Promise<PlanRunResult>} зібрані violations, виконані entries, повідомлення інфра-помилки.
372
+ */
373
+ async function detectPlanConcurrently(plan, { cwd, verbose, progress, log, concurrency }) {
374
+ const { results, infraError } = await runPlanConcurrently(plan, {
375
+ concurrency,
376
+ isSerial: item => isSerialLane(item.entry.ruleId, item.entry.concern.name),
377
+ runItem: (item, signal) => runPlanItem(item, { cwd, verbose, progress, log, signal })
378
+ })
379
+
380
+ if (infraError !== null && !(infraError instanceof DetectorError)) throw infraError
381
+
382
+ /** @type {LintViolation[]} */
383
+ const violations = []
384
+ /** @type {LintEntry[]} */
385
+ const ran = []
386
+ for (const { result } of results) {
387
+ if (!result) continue
388
+ ran.push(result.entry)
389
+ violations.push(...result.violations)
390
+ }
391
+ return { violations, ran, infraMessage: infraError?.message ?? null }
392
+ }
393
+
394
+ /**
395
+ * `data.line` детектора (не top-level поле `LintViolation` — лише деякі detector-и
396
+ * кладуть номер рядка в `data`, напр. `js/eslint`). Відсутність → 0 (перед усіма
397
+ * реальними номерами рядків), щоб сортування лишалось стабільним і передбачуваним.
398
+ * @param {LintViolation} v порушення.
399
+ * @returns {number} номер рядка або 0.
400
+ */
401
+ function violationLine(v) {
402
+ const line = v.data && typeof v.data === 'object' ? v.data.line : undefined
403
+ return typeof line === 'number' ? line : 0
404
+ }
405
+
406
+ /**
407
+ * Стабільне сортування за `(ruleId, concernId, file, line, reason)` — незалежно від порядку
408
+ * завершення concern-ів (важливо для конкурентного шляху; послідовний шлях сьогодні лише
409
+ * конкатенував violations у порядку виконання, без сортування за file/line/reason).
410
+ * @param {LintViolation[]} violations вхідний масив (не мутується).
411
+ * @returns {LintViolation[]} новий, стабільно сортований масив.
412
+ */
413
+ function sortViolations(violations) {
414
+ return violations.toSorted(
415
+ (a, b) =>
416
+ a.ruleId.localeCompare(b.ruleId) ||
417
+ a.concernId.localeCompare(b.concernId) ||
418
+ (a.file ?? '').localeCompare(b.file ?? '') ||
419
+ violationLine(a) - violationLine(b) ||
420
+ a.reason.localeCompare(b.reason)
421
+ )
422
+ }
423
+
298
424
  /**
299
425
  * Запускає detect-only прохід. Повертає всі violations і похідний exitCode.
300
426
  * @param {object} opts опції прогону.
@@ -339,43 +465,28 @@ export async function detectAll(opts) {
339
465
  : null
340
466
  const log = progress ? progress.log : baseLog
341
467
 
342
- /** @type {LintViolation[]} */
343
- const allViolations = []
344
- /** @type {LintEntry[]} */
345
- const ran = []
468
+ // Default 1 — production-паралелізм ще не пройшов benchmark-gates ADR 260716-1354;
469
+ // >1 лишається experimental override.
470
+ const concurrency = Math.max(1, Number(env['N_RULES_LINT_CONCURRENCY']) || 1)
471
+ const runOpts = { cwd, verbose, progress, log }
346
472
 
473
+ let planResult
347
474
  try {
348
- for (const { entry, files } of plan) {
349
- /** @type {LintContext} */
350
- const ctx = { cwd, ruleId: entry.ruleId, concernId: entry.concern.name, files, verbose }
351
- const key = `${entry.ruleId}/${entry.concern.name}`
352
- progress?.concernStart(key)
353
- if (verbose) {
354
- const countStr = files === undefined ? 'весь репо' : `${files.length} файл(ів)`
355
- log(` 🔍 ${key} [${entry.concern.lint.scope}] → ${countStr}\n`)
356
- }
357
- let result
358
- try {
359
- result = await runConcernDetector(entry.concern, ctx)
360
- } catch (error) {
361
- if (error instanceof DetectorError) {
362
- log(`💥 ${error.message}\n`)
363
- return { violations: allViolations, exitCode: 2, ran }
364
- }
365
- throw error
366
- }
367
- ran.push(entry)
368
- allViolations.push(...result.violations)
369
- progress?.detectSnapshot(key, result.violations.length)
370
- progress?.concernDone(key)
371
- if (verbose && result.diagnostics && result.diagnostics.length > 0) {
372
- log(renderDiagnostics(result.diagnostics))
373
- }
374
- }
475
+ planResult = await (concurrency > 1
476
+ ? detectPlanConcurrently(plan, { ...runOpts, concurrency })
477
+ : detectPlanSequentially(plan, runOpts))
375
478
  } finally {
376
479
  progress?.stop()
377
480
  }
378
481
 
482
+ const { ran, infraMessage } = planResult
483
+ const allViolations = sortViolations(planResult.violations)
484
+
485
+ if (infraMessage !== null) {
486
+ log(`💥 ${infraMessage}\n`)
487
+ return { violations: allViolations, exitCode: 2, ran }
488
+ }
489
+
379
490
  if (allViolations.length > 0) baseLog(renderViolations(allViolations))
380
491
  return { violations: allViolations, exitCode: allViolations.length > 0 ? 1 : 0, ran }
381
492
  }
@@ -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
  /**
@@ -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 |