@7n/rules 1.13.1 → 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.
- package/CHANGELOG.md +13 -0
- package/package.json +1 -1
- package/rules/graphql/tooling/docs/main.md +1 -1
- package/rules/graphql/tooling/main.mjs +4 -4
- package/rules/js/eslint/docs/fix-worker.md +6 -5
- package/rules/js/eslint/docs/main.md +1 -1
- package/rules/js/eslint/fix-worker.mjs +70 -30
- package/rules/js/eslint/main.mjs +8 -7
- package/rules/js-run/runtime/docs/main.md +1 -1
- package/rules/js-run/runtime/main.mjs +3 -3
- package/rules/k8s/manifests/main.mjs +6 -6
- package/rules/nginx-default-tpl/template/docs/main.md +1 -1
- package/rules/nginx-default-tpl/template/main.mjs +5 -5
- package/rules/tauri/tooling/docs/main.md +1 -1
- package/rules/tauri/tooling/main.mjs +1 -1
- package/scripts/lib/docs/ensure-tool.md +24 -22
- package/scripts/lib/docs/run-conftest-batch.md +5 -3
- package/scripts/lib/ensure-tool.mjs +125 -31
- package/scripts/lib/lint-surface/blocking-inventory.mjs +59 -0
- package/scripts/lib/lint-surface/docs/blocking-inventory.md +30 -0
- package/scripts/lib/lint-surface/docs/index.md +2 -0
- package/scripts/lib/lint-surface/docs/policy-lint-adapter.md +2 -3
- package/scripts/lib/lint-surface/docs/run-detectors.md +3 -1
- package/scripts/lib/lint-surface/docs/scheduler.md +39 -0
- package/scripts/lib/lint-surface/docs/types.md +2 -2
- package/scripts/lib/lint-surface/policy-lint-adapter.mjs +4 -3
- package/scripts/lib/lint-surface/run-detectors.mjs +142 -31
- package/scripts/lib/lint-surface/scheduler.mjs +96 -0
- package/scripts/lib/lint-surface/types.mjs +3 -0
- package/scripts/lib/run-conftest-batch.mjs +16 -11
- package/scripts/utils/docs/index.md +1 -0
- package/scripts/utils/docs/spawn-async.md +33 -0
- package/scripts/utils/spawn-async.mjs +112 -0
|
@@ -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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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`). Попереджає (не кидає) на помилку.
|
|
@@ -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:
|
|
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-поверхню
|
|
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:
|
|
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:
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
const
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
}
|