@7n/rules 1.49.25 → 1.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/rules/doc-files/check/docs/index.md +10 -0
- package/rules/doc-files/check/docs/main.md +4 -2
- package/rules/doc-files/check/main.mjs +12 -4
- package/rules/doc-files/docgen-crc/docs/index.md +9 -0
- package/rules/doc-files/docgen-crc/docs/main.md +47 -26
- package/rules/doc-files/docgen-crc/main.mjs +21 -4
- package/rules/doc-files/docgen-files-batch/docs/main.md +20 -14
- package/rules/doc-files/docgen-files-batch/main.mjs +54 -20
- package/rules/doc-files/docgen-gen/docs/main.md +35 -3
- package/rules/doc-files/docgen-gen/main.mjs +253 -19
- package/rules/doc-files/docgen-prompts/docs/main.md +18 -12
- package/rules/doc-files/docgen-prompts/main.mjs +19 -12
- package/rules/doc-files/docgen-scan/docs/main.md +44 -32
- package/rules/doc-files/docgen-scan/main.mjs +6 -3
- package/rules/doc-files/docgen-test-context/docs/index.md +9 -0
- package/rules/doc-files/docgen-test-context/docs/main.md +57 -0
- package/rules/doc-files/docgen-test-context/main.mjs +212 -0
- package/rules/doc-files/main.mdc +37 -6
- package/rules/k8s/manifests/main.mjs +94 -38
- package/scripts/lib/docs/resolve-plugins.md +26 -53
- package/scripts/lib/lint-surface/lint-lock.mjs +81 -16
- package/scripts/lib/lint-surface/progress.mjs +15 -3
- package/scripts/lib/lint-surface/run-detectors.mjs +9 -1
- package/scripts/lib/lint-surface/types.mjs +2 -0
- package/scripts/lib/resolve-plugins.mjs +70 -6
- package/skills/doc-files/SKILL.md +21 -6
|
@@ -6690,8 +6690,13 @@ export async function lint(ctx) {
|
|
|
6690
6690
|
// (spec docs/specs/2026-07-02-text-check-per-file-split-design.md §6) ──
|
|
6691
6691
|
const dirs = await findK8sRoots(root, ignorePaths)
|
|
6692
6692
|
if (dirs.length > 0) {
|
|
6693
|
-
const ks = await runKubescape(dirs, root, verbose)
|
|
6694
|
-
if (ks !== 0 && ks !== 127)
|
|
6693
|
+
const ks = await runKubescape(dirs, root, verbose, { onProgress: ctx.reportProgressDetail })
|
|
6694
|
+
if (ks.status !== 0 && ks.status !== 127) {
|
|
6695
|
+
const message = ks.timedOut
|
|
6696
|
+
? `kubescape timeout: ${relative(root, ks.target)} (ліміт ${KUBESCAPE_SCAN_TIMEOUT_LABEL})`
|
|
6697
|
+
: 'kubescape знайшов ризики у маніфестах (k8s.mdc)'
|
|
6698
|
+
fail(message, 'kubescape')
|
|
6699
|
+
}
|
|
6695
6700
|
}
|
|
6696
6701
|
|
|
6697
6702
|
// ── JS/rego cross-file перевірки (колишній check, read-only) ──
|
|
@@ -6798,6 +6803,10 @@ export async function findK8sRoots(root, ignorePaths = []) {
|
|
|
6798
6803
|
const KUBESCAPE_EXCEPTIONS_FILE = '.kubescape-exceptions.json'
|
|
6799
6804
|
const KUSTOMIZATION_FILE = 'kustomization.yaml'
|
|
6800
6805
|
const KUBESCAPE_MISSING_HINT = 'kubescape не знайдено в PATH. Встанови з https://github.com/kubescape/kubescape#readme'
|
|
6806
|
+
/** Bounded wall-clock limit кожного зовнішнього scan; spawnAsync завершує child process. */
|
|
6807
|
+
export const KUBESCAPE_SCAN_TIMEOUT_MS = 5 * 60_000
|
|
6808
|
+
const KUBESCAPE_SCAN_TIMEOUT_LABEL = '5 хв'
|
|
6809
|
+
const KUBESCAPE_SCAN_TIMEOUT_ARG = '5m'
|
|
6801
6810
|
const KUBERNETES_VERSION = '1.33.9'
|
|
6802
6811
|
const DATREE_CRD_SCHEMA_LOCATION =
|
|
6803
6812
|
'https://datreeio.github.io/CRDs-catalog/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json'
|
|
@@ -7004,9 +7013,11 @@ async function runKustomizeBuild(kubectlPath, dir, verbose = false) {
|
|
|
7004
7013
|
* @param {string|Buffer} manifest вміст маніфеста для сканування.
|
|
7005
7014
|
* @param {string} root корінь репозиторію (для user-файла винятків).
|
|
7006
7015
|
* @param {boolean} [verbose] показати повний нативний вивід kubescape (`stdio: 'inherit'`).
|
|
7007
|
-
* @
|
|
7016
|
+
* @param {{ spawn?: typeof spawnAsync, useDefault?: boolean }} [opts] тестова інʼєкція spawn і cache-safe режим після першого target-а.
|
|
7017
|
+
* @returns {Promise<{ status: number, enoent: boolean, timedOut: boolean }>} exit-код, відсутність тула і timeout.
|
|
7008
7018
|
*/
|
|
7009
|
-
async function runKubescapeManifest(kubescapePath, manifest, root, verbose = false) {
|
|
7019
|
+
export async function runKubescapeManifest(kubescapePath, manifest, root, verbose = false, opts = {}) {
|
|
7020
|
+
const exec = opts.spawn ?? spawnAsync
|
|
7010
7021
|
const dir = mkdtempSync(join(tmpdir(), 'nitra-cursor-k8s-'))
|
|
7011
7022
|
const file = join(dir, 'manifest.yaml')
|
|
7012
7023
|
const exceptionsArgs = buildKubescapeExceptionsArgs(root, autoJobCronJobProbeExceptions(String(manifest)))
|
|
@@ -7014,13 +7025,24 @@ async function runKubescapeManifest(kubescapePath, manifest, root, verbose = fal
|
|
|
7014
7025
|
try {
|
|
7015
7026
|
writeFileSync(file, manifest)
|
|
7016
7027
|
try {
|
|
7017
|
-
const r = await
|
|
7018
|
-
|
|
7019
|
-
|
|
7020
|
-
|
|
7028
|
+
const r = await exec(
|
|
7029
|
+
kubescapePath,
|
|
7030
|
+
[
|
|
7031
|
+
'scan',
|
|
7032
|
+
file,
|
|
7033
|
+
'--severity-threshold',
|
|
7034
|
+
'high',
|
|
7035
|
+
'--scan-timeout',
|
|
7036
|
+
KUBESCAPE_SCAN_TIMEOUT_ARG,
|
|
7037
|
+
...(opts.useDefault ? ['--use-default'] : []),
|
|
7038
|
+
...exceptionsArgs
|
|
7039
|
+
],
|
|
7040
|
+
{ stdio: verbose ? 'inherit' : 'pipe', timeoutMs: KUBESCAPE_SCAN_TIMEOUT_MS }
|
|
7041
|
+
)
|
|
7042
|
+
return { status: r.exitCode ?? 1, enoent: false, timedOut: r.timedOut === true }
|
|
7021
7043
|
} catch (error) {
|
|
7022
7044
|
const enoent = Boolean(error && error.code === 'ENOENT')
|
|
7023
|
-
return { status: 1, enoent }
|
|
7045
|
+
return { status: 1, enoent, timedOut: false }
|
|
7024
7046
|
}
|
|
7025
7047
|
} finally {
|
|
7026
7048
|
cleanupKubescapeExceptionsArgs(exceptionsArgs, root)
|
|
@@ -7036,24 +7058,37 @@ async function runKubescapeManifest(kubescapePath, manifest, root, verbose = fal
|
|
|
7036
7058
|
* @param {string} dir каталог для сканування.
|
|
7037
7059
|
* @param {string} root корінь репозиторію (для user-файла винятків).
|
|
7038
7060
|
* @param {boolean} [verbose] показати повний нативний вивід kubescape (`stdio: 'inherit'`) і хід сканування.
|
|
7039
|
-
* @
|
|
7061
|
+
* @param {{ spawn?: typeof spawnAsync, useDefault?: boolean }} [opts] тестова інʼєкція spawn і cache-safe режим після першого target-а.
|
|
7062
|
+
* @returns {Promise<{ status: number, timedOut: boolean }>} exit-код (127 якщо тул відсутній) і timeout.
|
|
7040
7063
|
*/
|
|
7041
|
-
async function scanRawK8sDir(kubescapePath, dir, root, verbose = false) {
|
|
7064
|
+
async function scanRawK8sDir(kubescapePath, dir, root, verbose = false, opts = {}) {
|
|
7065
|
+
const exec = opts.spawn ?? spawnAsync
|
|
7042
7066
|
if (verbose) console.log(`run-k8s: kubescape scan ${dir} (без kustomization — сирий dir-скан)`)
|
|
7043
7067
|
const yamlText = await readAllYamlTextUnderDir(dir)
|
|
7044
7068
|
const exceptionsArgs = buildKubescapeExceptionsArgs(root, autoJobCronJobProbeExceptions(yamlText))
|
|
7045
7069
|
if (verbose && exceptionsArgs.length > 0) console.log(`run-k8s: kubescape exceptions — ${exceptionsArgs[1]}`)
|
|
7046
7070
|
try {
|
|
7047
|
-
const r = await
|
|
7048
|
-
|
|
7049
|
-
|
|
7050
|
-
|
|
7071
|
+
const r = await exec(
|
|
7072
|
+
kubescapePath,
|
|
7073
|
+
[
|
|
7074
|
+
'scan',
|
|
7075
|
+
dir,
|
|
7076
|
+
'--severity-threshold',
|
|
7077
|
+
'high',
|
|
7078
|
+
'--scan-timeout',
|
|
7079
|
+
KUBESCAPE_SCAN_TIMEOUT_ARG,
|
|
7080
|
+
...(opts.useDefault ? ['--use-default'] : []),
|
|
7081
|
+
...exceptionsArgs
|
|
7082
|
+
],
|
|
7083
|
+
{ stdio: verbose ? 'inherit' : 'pipe', timeoutMs: KUBESCAPE_SCAN_TIMEOUT_MS }
|
|
7084
|
+
)
|
|
7085
|
+
return { status: r.exitCode ?? 1, timedOut: r.timedOut === true }
|
|
7051
7086
|
} catch (error) {
|
|
7052
7087
|
if (error && error.code === 'ENOENT') {
|
|
7053
7088
|
console.error(KUBESCAPE_MISSING_HINT)
|
|
7054
|
-
return 127
|
|
7089
|
+
return { status: 127, timedOut: false }
|
|
7055
7090
|
}
|
|
7056
|
-
return 1
|
|
7091
|
+
return { status: 1, timedOut: false }
|
|
7057
7092
|
} finally {
|
|
7058
7093
|
cleanupKubescapeExceptionsArgs(exceptionsArgs, root)
|
|
7059
7094
|
}
|
|
@@ -7066,21 +7101,22 @@ async function scanRawK8sDir(kubescapePath, dir, root, verbose = false) {
|
|
|
7066
7101
|
* @param {string[]} kdirs каталоги з kustomization.
|
|
7067
7102
|
* @param {string} root корінь репозиторію (для user-файла винятків).
|
|
7068
7103
|
* @param {boolean} [verbose] показати повний нативний вивід kubectl/kubescape і хід сканування.
|
|
7069
|
-
* @
|
|
7104
|
+
* @param {{ spawn?: typeof spawnAsync, useDefault?: boolean }} [opts] тестова інʼєкція spawn і cache-safe режим після першого target-а.
|
|
7105
|
+
* @returns {Promise<{ status: number, target: string|null, timedOut: boolean }>} результат одного або кількох target-ів.
|
|
7070
7106
|
*/
|
|
7071
|
-
async function scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, root, verbose = false) {
|
|
7107
|
+
async function scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, root, verbose = false, opts = {}) {
|
|
7072
7108
|
for (const kdir of kdirs) {
|
|
7073
7109
|
if (verbose) console.log(`run-k8s: kubectl kustomize ${kdir} | kubescape scan <tmp>`)
|
|
7074
7110
|
const build = await runKustomizeBuild(kubectlPath, kdir, verbose)
|
|
7075
|
-
if (build.status !== 0) return build.status
|
|
7076
|
-
const ks = await runKubescapeManifest(kubescapePath, build.stdout, root, verbose)
|
|
7111
|
+
if (build.status !== 0) return { status: build.status, target: kdir, timedOut: false }
|
|
7112
|
+
const ks = await runKubescapeManifest(kubescapePath, build.stdout, root, verbose, opts)
|
|
7077
7113
|
if (ks.enoent) {
|
|
7078
7114
|
console.error(KUBESCAPE_MISSING_HINT)
|
|
7079
|
-
return 127
|
|
7115
|
+
return { status: 127, target: kdir, timedOut: false }
|
|
7080
7116
|
}
|
|
7081
|
-
if (ks.status !== 0) return ks.status
|
|
7117
|
+
if (ks.status !== 0) return { status: ks.status, target: kdir, timedOut: ks.timedOut }
|
|
7082
7118
|
}
|
|
7083
|
-
return 0
|
|
7119
|
+
return { status: 0, target: null, timedOut: false }
|
|
7084
7120
|
}
|
|
7085
7121
|
|
|
7086
7122
|
/**
|
|
@@ -7090,27 +7126,47 @@ async function scanKustomizeK8sDirs(kubectlPath, kubescapePath, kdirs, root, ver
|
|
|
7090
7126
|
* @param {string[]} dirs абсолютні шляхи k8s-коренів.
|
|
7091
7127
|
* @param {string} root корінь репозиторію (для user-файла винятків).
|
|
7092
7128
|
* @param {boolean} [verbose] показати повний нативний вивід kubectl/kubescape (`stdio: 'inherit'`) і хід сканування.
|
|
7093
|
-
* @
|
|
7129
|
+
* @param {{ kubescapePath?: string, kubectlPath?: string, spawn?: typeof spawnAsync, onProgress?: (progress: { label: string, done: number, total: number, current: string }) => void }} [opts] залежності й granular live progress.
|
|
7130
|
+
* @returns {Promise<{ status: number, target: string|null, timedOut: boolean }>} результат усіх target-ів.
|
|
7094
7131
|
*/
|
|
7095
|
-
async function runKubescape(dirs, root, verbose = false) {
|
|
7096
|
-
const kubescapePath = ensureTool('kubescape')
|
|
7132
|
+
export async function runKubescape(dirs, root, verbose = false, opts = {}) {
|
|
7133
|
+
const kubescapePath = opts.kubescapePath ?? ensureTool('kubescape')
|
|
7097
7134
|
let kubectlPath = null
|
|
7135
|
+
/** @type {Array<{ kind: 'raw'|'kustomize', dir: string }>} */
|
|
7136
|
+
const targets = []
|
|
7098
7137
|
for (const d of dirs) {
|
|
7099
7138
|
const kdirs = await findKustomizationDirs(d)
|
|
7100
7139
|
if (kdirs.length === 0) {
|
|
7101
|
-
|
|
7102
|
-
|
|
7103
|
-
|
|
7140
|
+
targets.push({ kind: 'raw', dir: d })
|
|
7141
|
+
} else {
|
|
7142
|
+
for (const dir of kdirs) targets.push({ kind: 'kustomize', dir })
|
|
7104
7143
|
}
|
|
7105
|
-
|
|
7106
|
-
|
|
7107
|
-
|
|
7108
|
-
|
|
7109
|
-
|
|
7144
|
+
}
|
|
7145
|
+
|
|
7146
|
+
let done = 0
|
|
7147
|
+
let cacheReady = false
|
|
7148
|
+
for (const target of targets) {
|
|
7149
|
+
opts.onProgress?.({ label: 'kubescape', done, total: targets.length, current: relative(root, target.dir) })
|
|
7150
|
+
if (target.kind === 'raw') {
|
|
7151
|
+
const result = await scanRawK8sDir(kubescapePath, target.dir, root, verbose, { ...opts, useDefault: cacheReady })
|
|
7152
|
+
if (result.status !== 0) return { ...result, target: target.dir }
|
|
7153
|
+
} else {
|
|
7154
|
+
if (kubectlPath === null) {
|
|
7155
|
+
kubectlPath = opts.kubectlPath ?? resolveCmd('kubectl')
|
|
7156
|
+
if (!kubectlPath) {
|
|
7157
|
+
console.error('kubectl не знайдено в PATH. Встанови з https://kubernetes.io/docs/tasks/tools/#kubectl')
|
|
7158
|
+
return { status: 127, target: target.dir, timedOut: false }
|
|
7159
|
+
}
|
|
7110
7160
|
}
|
|
7161
|
+
const result = await scanKustomizeK8sDirs(kubectlPath, kubescapePath, [target.dir], root, verbose, {
|
|
7162
|
+
...opts,
|
|
7163
|
+
useDefault: cacheReady
|
|
7164
|
+
})
|
|
7165
|
+
if (result.status !== 0) return result
|
|
7111
7166
|
}
|
|
7112
|
-
|
|
7113
|
-
|
|
7167
|
+
cacheReady = true
|
|
7168
|
+
done += 1
|
|
7169
|
+
opts.onProgress?.({ label: 'kubescape', done, total: targets.length, current: relative(root, target.dir) })
|
|
7114
7170
|
}
|
|
7115
|
-
return 0
|
|
7171
|
+
return { status: 0, target: null, timedOut: false }
|
|
7116
7172
|
}
|
|
@@ -3,71 +3,35 @@ type: JS Module
|
|
|
3
3
|
title: resolve-plugins.mjs
|
|
4
4
|
resource: npm/scripts/lib/resolve-plugins.mjs
|
|
5
5
|
docgen:
|
|
6
|
-
crc:
|
|
7
|
-
model:
|
|
8
|
-
tier:
|
|
9
|
-
score:
|
|
10
|
-
issues:
|
|
6
|
+
crc: e7eaf408
|
|
7
|
+
model: openai-codex/gpt-5.5
|
|
8
|
+
tier: cloud-avg
|
|
9
|
+
score: 90
|
|
10
|
+
issues: surzhik,judge-refine:kept-original,judge:inaccurate:0.99
|
|
11
|
+
judgeModel: openai-codex/gpt-5.4-mini
|
|
11
12
|
---
|
|
12
13
|
|
|
13
14
|
## Огляд
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
Файл визначає, які плагіни `@7n/rules` активні для проєкту, щоб core міг підключити правильні каталоги правил, capabilities, документаційні розширення та handlers. Джерело правди — поле `plugins: string[]` у `.n-rules.json`: явний `[]` вимикає плагіни й автодетект, а відсутнє поле запускає повний `detectPluginsFromRepo`.
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
Перевіряє наявність файлу-сигналу у корені або підтеках до `maxDepth` рівнів. Використовує BFS-обхід, який пропускає приховані директорії, `node_modules`, та `target`, для забезпечення ефективного виклику на hot-path. Повертає `true` при знаходженні сигналу. Викликає `listScannableSubdirs`.
|
|
20
|
-
|
|
21
|
-
### listScannableSubdirs
|
|
22
|
-
Отримує видимі підтеки для неглибокого скану, ігноруючи приховані та службові директорії. Повертає масив абсолютних шляхів підтеків (порожній, якщо директорія нечитабельна).
|
|
23
|
-
|
|
24
|
-
### readRepositoryUrl
|
|
25
|
-
Отримує `repository.url` з кореневого `package.json` (як рядок або об'єкт). Повертає `null`, якщо URL відсутній або файл нечитабельний.
|
|
26
|
-
|
|
27
|
-
### hasGithubWorkflows
|
|
28
|
-
Перевіряє наявність файлів у директорії `.github/workflows/` будь-якого YAML/YML файлу. Повертає `true` при наявності.
|
|
29
|
-
|
|
30
|
-
### detectCiPlugins
|
|
31
|
-
Автоматично визначає CI-плагіни за станом репозиторію. Пріоритет надається файловим сигналам. Якщо файлових сигналів немає, використовується `repository.url` з кореневого `package.json`. Якщо обидва сигнали присутні, обидва плагіни повертаються. Якщо немає жодного сигналу, повертається порожній масив. Викликає `hasGithubWorkflows` та `readRepositoryUrl`.
|
|
18
|
+
Якщо `plugins` непорожній і містить лише пакети за конвенцією `@7n/rules-<category>-<name>`, автодетект домішує тільки відсутні категорії. Будь-який сторонній пакет у списку вимикає backfill повністю, щоб змішана або користувацька конфігурація залишалась ручною й передбачуваною.
|
|
32
19
|
|
|
33
|
-
|
|
34
|
-
Автоматично визначає плагіни за станом репозиторію. Використовує файлові сигнали як пріоритет, з fallback на `repository.url` лише для CI-плагінів, коли файлових сигналів немає. Для Rust використовується перевірка до трьох рівнів підтек. Викликає `detectCiPlugins` та `hasLangSignal`.
|
|
20
|
+
Автодетект спирається на файлові сигнали `.github/workflows/*.yml` і `azure-pipelines.yml`, а за їх відсутності — на `repository.url` з ознаками `github.com` або `dev.azure.com`. Резолв працює fail-safe: недоступні, не встановлені або несумісні плагіни не зривають lint/sync, а пропускаються; для несумісного `requiresPluginApi > PLUGIN_API_VERSION` видається warning, крім режиму `quiet:true`, де пропуск тихий. Результати кешуються в межах прогону.
|
|
35
21
|
|
|
36
|
-
|
|
37
|
-
Визначає категорію плагіна на основі naming convention `@7n/rules-<category>-<name>`. Повертає `null` для пакетів, що не відповідають цій конвенції (сторонні/кастомні плагіни) — такий пакет ніколи не з'являється сам через автодетект, а якщо присутній у явному `config.plugins`, вимикає backfill категорій для всього списку.
|
|
38
|
-
|
|
39
|
-
### resolvePluginList
|
|
40
|
-
Обчислює список плагінів проєкту. Використовує явне поле `config.plugins` з `.n-rules.json` або автоматичний автодетект. Явний пустий масив означає "плагіни вимкнено". Якщо `config.plugins` містить сторонній пакет, який не є `@7n/rules-*`, то backfill для заповнення списку вимикається повністю.
|
|
41
|
-
|
|
42
|
-
### computePluginList
|
|
43
|
-
Обчислює список плагінів без кешу. Використовує сире значення `config.plugins` з `.n-rules.json`. Повертає список плагінів. Викликає `detectPluginsFromRepo` та `pluginCategory`.
|
|
44
|
-
|
|
45
|
-
### ensurePluginInstalled
|
|
46
|
-
Гарантує наявність пакета: якщо він відсутній у `node_modules`, виконує `bun add -d <pkg>` для додання його як `devDependency`. Фейл повертає `false` з попередженням.
|
|
47
|
-
|
|
48
|
-
### readPluginManifest
|
|
49
|
-
Отримує нормалізований маніфест плагіна з його `package.json` з блоку `"n-rules"`.
|
|
50
|
-
|
|
51
|
-
### resolvePlugins
|
|
52
|
-
Виконує повне визначення доступних плагінів проєкту з використанням кешу. Дозволяє встановити вже встановлені пакети через `allowInstall: false` (для hot-path хука), ігнорує `bun add`, ігнорує `quiet` для уникнення попереджень при виклику на кожному файлі. Повертає масив доступних плагінів.
|
|
22
|
+
## Поведінка
|
|
53
23
|
|
|
54
|
-
|
|
55
|
-
Отримує шляхи до каталогів для всіх поверхонь ядра. Ядро має пріоритет. Надає масив об'єктів, що містять ім'я плагіна, шлях до його директорії та шлях до його кореня пакета.
|
|
24
|
+
`resolvePluginList` бере список із `.n-rules.json`: відсутнє поле запускає повний автодетект через `detectPluginsFromRepo`, явний порожній список вимикає плагіни, а непорожній список може доповнюватися лише відсутніми категоріями. `pluginCategory` визначає ці категорії; якщо у списку є сторонній пакет, backfill не застосовується.
|
|
56
25
|
|
|
57
|
-
|
|
58
|
-
Отримує набір capability-рядків з усіх доступних плагінів, що використовуються для визначення `requires.capability` у `concern.json`.
|
|
26
|
+
`detectPluginsFromRepo` зіставляє файлові сигнали з `KNOWN_CI_PLUGINS` і `KNOWN_LANG_PLUGINS`. Для CI файлові сигнали мають пріоритет, а fallback за URL з кореневого `package.json` використовується лише коли CI-конфігів немає. Для мов працюють тільки файлові сигнали; під час неглибокого скану службові й приховані каталоги на кшталт `.github`, `.git` і `node_modules` не обходяться.
|
|
59
27
|
|
|
60
|
-
|
|
61
|
-
Агрегує розширення файлів, що використовуються для генерації мапи від розширення до типу-мітка. Повертає мапу `extension -> type-мітка`.
|
|
28
|
+
`resolvePlugins` перетворює обрані імена на доступні плагіни: за потреби делегує встановлення в `ensurePluginInstalled`, читає маніфест, відкидає несумісні плагіни й не зриває виконання через недоступний пакет. `KNOWN_PLUGIN_RANGES` обмежує версії first-party плагінів під час автоматичного встановлення, щоб core і плагіни лишалися сумісними.
|
|
62
29
|
|
|
63
|
-
|
|
64
|
-
Повертає список плагінів, які задекларовані у `config.plugins`, але відсутні у `node_modules`. Нічого не встановлює і нічого не друкує — чистий предикат для explicit CLI-діагностики.
|
|
30
|
+
Результати резолву кешуються в межах процесу, щоб повторні виклики з тих самих даних не дублювали сканування, встановлення та попередження. `clearPluginResolveCache` скидає цей спільний стан для ізольованих перевірок.
|
|
65
31
|
|
|
66
|
-
|
|
67
|
-
Отримує шляхи до модулів-обробників для певного extension-point правила ядра.
|
|
32
|
+
`resolveRulesDirs` використовує `resolvePlugins`, щоб зібрати джерела правил: core іде першим, далі активні плагіни у визначеному порядку. `getActiveCapabilities` агрегує capabilities з маніфестів; ці значення використовуються для гейтів у `concern.json`.
|
|
68
33
|
|
|
69
|
-
|
|
70
|
-
Скидає кеш, призначений для тестів.
|
|
34
|
+
`getDocFilesExtensions` і `getHandlers` читають внесок активних плагінів для документаційних розширень та extension-points. `getUnavailableDeclaredPlugins` окремо перевіряє саме явно задекларовані, але недоступні пакети, щоб CLI міг показати діагностику без автоматичного встановлення.
|
|
71
35
|
|
|
72
36
|
## Публічний API
|
|
73
37
|
|
|
@@ -98,8 +62,17 @@ per-категорійний backfill для всього списку (див.
|
|
|
98
62
|
(через `resolveRulesDirs` тощо) і прямий виклик у sync-CLI інакше дублювали б і файловий
|
|
99
63
|
скан, і warning про backfill.
|
|
100
64
|
ігнорується при cache hit — warning друкується щонайбільше раз на `(root, declared)` за процес
|
|
65
|
+
- KNOWN_PLUGIN_RANGES — Сумісний semver-range для first-party плагінів: обмежує автоматичну інсталяцію (`ensurePluginInstalled`)
|
|
66
|
+
поточною core-сумісною лінією, щоб майбутній несумісний major/minor плагіна не встановився
|
|
67
|
+
мовчки поверх старого core (Фаза 0, spec 2026-07-27-universal-plugin-slots-lang-php-extraction.md
|
|
68
|
+
§10). Для `0.x`-пакетів — caret на поточний minor (`^0.22`, а не голий `^0`, який під caret-
|
|
69
|
+
семантикою розгортається у весь діапазон `0.x`); для `>=1` — caret на поточний major (`^1`).
|
|
70
|
+
Невідомий (сторонній, не з цієї таблиці) пакет інсталюється без обмеження версії — як і раніше.
|
|
101
71
|
- ensurePluginInstalled — Гарантує, що плагін встановлений: якщо `node_modules/<pkg>` нема — `bun add -d <pkg>`
|
|
102
|
-
(дописує devDependency і ставить).
|
|
72
|
+
(дописує devDependency і ставить). Для first-party пакетів з `KNOWN_PLUGIN_RANGES` версія
|
|
73
|
+
обмежується сумісним range (`<pkg>@^<major>` або `@^<major>.<minor>` для `0.x`); сторонні
|
|
74
|
+
пакети встановлюються без обмеження, bun сам резолвить latest. Фейл — warning + false, без
|
|
75
|
+
винятку.
|
|
103
76
|
- resolvePlugins — Повний резолв плагінів проєкту (з кешем на процес).
|
|
104
77
|
лише вже встановлені пакети, без `bun add`; `quiet` — без warning-ів (hook на кожен файл)
|
|
105
78
|
- resolveRulesDirs — Rules-каталоги для всіх поверхонь ядра: ядро першим (його правила/концерни виграють
|
|
@@ -66,6 +66,15 @@ const NON_TTY_WAIT_LOG_INTERVAL_MS = 10_000
|
|
|
66
66
|
/** Знімок прогресу вважається живим, якщо оновлювався не давніше за це. */
|
|
67
67
|
const PROGRESS_FRESH_MS = 60_000
|
|
68
68
|
|
|
69
|
+
/** Версія контракту published snapshot-а; observer-и ігнорують неповні старі записи. */
|
|
70
|
+
const PROGRESS_SNAPSHOT_VERSION = 2
|
|
71
|
+
|
|
72
|
+
/** Частота heartbeat owner-а: довгий один target лишається видимим для процесу в черзі. */
|
|
73
|
+
const PUBLISH_HEARTBEAT_INTERVAL_MS = 5_000
|
|
74
|
+
|
|
75
|
+
/** Мінімум завершених targets, коли швидкість вже достатня для обережного ETA. */
|
|
76
|
+
const ETA_MIN_COMPLETED_TARGETS = 3
|
|
77
|
+
|
|
69
78
|
/**
|
|
70
79
|
* Fingerprint для TTL-дедуплікації: стан робочого дерева + варіант виклику lint.
|
|
71
80
|
* null (→ дедуплікація вимкнена, черга працює) коли:
|
|
@@ -90,26 +99,68 @@ export function lintLockFingerprint(variant, getTreeFp = worktreeFingerprint) {
|
|
|
90
99
|
* Publisher прогресу активного прогону: приймає знімки від
|
|
91
100
|
* `createProgressReporter({ onUpdate })` і (throttled) пише їх у стан-файл,
|
|
92
101
|
* звідки процеси в черзі читають прогрес-бар активного прогону.
|
|
93
|
-
* @param {{file?: string, minIntervalMs?: number}} [opts] override-и для тестів
|
|
94
|
-
* @returns {{ onUpdate: (snap: { done: number, total: number, found: number, fixed: number, current: string }) => void, stop: () => void }} publisher
|
|
102
|
+
* @param {{file?: string, minIntervalMs?: number, heartbeatIntervalMs?: number, now?: () => number}} [opts] override-и для тестів
|
|
103
|
+
* @returns {{ onUpdate: (snap: { done: number, total: number, found: number, fixed: number, current: string, detail?: { label: string, done: number, total: number, current: string } | null }) => void, stop: () => void }} publisher
|
|
95
104
|
*/
|
|
96
105
|
export function createProgressPublisher(opts = {}) {
|
|
97
106
|
const file = opts.file ?? PROGRESS_FILE
|
|
98
107
|
const minIntervalMs = opts.minIntervalMs ?? PUBLISH_MIN_INTERVAL_MS
|
|
108
|
+
const heartbeatIntervalMs = opts.heartbeatIntervalMs ?? PUBLISH_HEARTBEAT_INTERVAL_MS
|
|
109
|
+
const now = opts.now ?? Date.now
|
|
99
110
|
let lastWriteAt = 0
|
|
111
|
+
let lastSnap = null
|
|
112
|
+
let phaseStartedAt = 0
|
|
113
|
+
|
|
114
|
+
/** Записує атомарно достатній для observer-а snapshot; помилки лишаються best-effort. */
|
|
115
|
+
function publish(snap, heartbeat = false) {
|
|
116
|
+
const at = now()
|
|
117
|
+
const detail = snap.detail ?? null
|
|
118
|
+
if (!heartbeat && (!lastSnap || lastSnap.current !== snap.current || lastSnap.detail?.label !== detail?.label))
|
|
119
|
+
phaseStartedAt = at
|
|
120
|
+
const completed = detail?.done ?? 0
|
|
121
|
+
const remaining = Math.max(0, (detail?.total ?? 0) - completed)
|
|
122
|
+
const elapsed = phaseStartedAt > 0 ? at - phaseStartedAt : 0
|
|
123
|
+
const etaMs =
|
|
124
|
+
!heartbeat && detail && completed >= ETA_MIN_COMPLETED_TARGETS && remaining > 0 && elapsed > 0
|
|
125
|
+
? Math.round((elapsed / completed) * remaining)
|
|
126
|
+
: (lastSnap?.etaMs ?? null)
|
|
127
|
+
const record = {
|
|
128
|
+
version: PROGRESS_SNAPSHOT_VERSION,
|
|
129
|
+
pid: process.pid,
|
|
130
|
+
cwd: processCwd(),
|
|
131
|
+
updatedAt: heartbeat ? (lastSnap?.updatedAt ?? at) : at,
|
|
132
|
+
heartbeatAt: at,
|
|
133
|
+
phase: snap.current,
|
|
134
|
+
step: detail?.label ?? null,
|
|
135
|
+
done: snap.done,
|
|
136
|
+
total: snap.total,
|
|
137
|
+
found: snap.found,
|
|
138
|
+
fixed: snap.fixed,
|
|
139
|
+
current: snap.current,
|
|
140
|
+
detail,
|
|
141
|
+
etaMs
|
|
142
|
+
}
|
|
143
|
+
lastSnap = record
|
|
144
|
+
try {
|
|
145
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
146
|
+
writeFileSync(file, JSON.stringify(record))
|
|
147
|
+
} catch {
|
|
148
|
+
// best-effort: без стан-файлу процеси в черзі просто не побачать бар
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
const heartbeat = setInterval(() => {
|
|
152
|
+
if (lastSnap) publish(lastSnap, true)
|
|
153
|
+
}, heartbeatIntervalMs)
|
|
154
|
+
heartbeat.unref?.()
|
|
100
155
|
return {
|
|
101
156
|
onUpdate: snap => {
|
|
102
|
-
const
|
|
103
|
-
if (
|
|
104
|
-
lastWriteAt =
|
|
105
|
-
|
|
106
|
-
mkdirSync(dirname(file), { recursive: true })
|
|
107
|
-
writeFileSync(file, JSON.stringify({ pid: process.pid, updatedAt: now, cwd: processCwd(), ...snap }))
|
|
108
|
-
} catch {
|
|
109
|
-
// best-effort: без стан-файлу процеси в черзі просто не побачать бар
|
|
110
|
-
}
|
|
157
|
+
const at = now()
|
|
158
|
+
if (at - lastWriteAt < minIntervalMs) return
|
|
159
|
+
lastWriteAt = at
|
|
160
|
+
publish(snap)
|
|
111
161
|
},
|
|
112
162
|
stop: () => {
|
|
163
|
+
clearInterval(heartbeat)
|
|
113
164
|
try {
|
|
114
165
|
rmSync(file, { force: true })
|
|
115
166
|
} catch {
|
|
@@ -155,13 +206,14 @@ function listQueue(queueDir) {
|
|
|
155
206
|
* застарілий або належить не поточному власнику лока.
|
|
156
207
|
* @param {number} ownerPid PID власника лока
|
|
157
208
|
* @param {string} progressFile шлях стан-файлу прогресу
|
|
158
|
-
* @returns {{ done: number, total: number, found: number, fixed: number, current: string } | null} знімок
|
|
209
|
+
* @returns {{ done: number, total: number, found: number, fixed: number, current: string, phase: string, step: string|null, detail: { label: string, done: number, total: number, current: string }|null, etaMs: number|null, updatedAt: number, heartbeatAt: number } | null} знімок
|
|
159
210
|
*/
|
|
160
|
-
function readOwnerProgress(ownerPid, progressFile) {
|
|
211
|
+
export function readOwnerProgress(ownerPid, progressFile) {
|
|
161
212
|
try {
|
|
162
213
|
const snap = JSON.parse(readFileSync(progressFile, 'utf8'))
|
|
163
214
|
if (snap?.pid !== ownerPid) return null
|
|
164
|
-
if (
|
|
215
|
+
if (snap?.version !== PROGRESS_SNAPSHOT_VERSION || typeof snap.heartbeatAt !== 'number') return null
|
|
216
|
+
if (Date.now() - snap.heartbeatAt > PROGRESS_FRESH_MS) return null
|
|
165
217
|
return snap
|
|
166
218
|
} catch {
|
|
167
219
|
return null
|
|
@@ -180,7 +232,11 @@ export function renderWaitLine(owner, queue, snap) {
|
|
|
180
232
|
const myIdx = queue.findIndex(e => e.pid === process.pid)
|
|
181
233
|
const pos = (myIdx === -1 ? queue.length : myIdx) + 1
|
|
182
234
|
const ownerDir = owner.cwd ? ` (${basename(owner.cwd)})` : ''
|
|
183
|
-
const
|
|
235
|
+
const eta =
|
|
236
|
+
snap?.etaMs !== null && snap?.etaMs !== undefined && Date.now() - snap.updatedAt <= PROGRESS_FRESH_MS
|
|
237
|
+
? ` · ETA ≈ ${formatEta(snap.etaMs)}`
|
|
238
|
+
: ''
|
|
239
|
+
const bar = snap ? ` · ${renderProgressLine(snap)}${eta}` : ''
|
|
184
240
|
const others = queue
|
|
185
241
|
.filter(e => e.pid !== process.pid)
|
|
186
242
|
.map(e => `pid ${e.pid} (${basename(e.cwd)})`)
|
|
@@ -189,6 +245,13 @@ export function renderWaitLine(owner, queue, snap) {
|
|
|
189
245
|
return `⏳ lint --full у черзі #${pos}/${Math.max(queue.length, pos)} · працює pid ${owner.pid}${ownerDir}${bar}${tail}`
|
|
190
246
|
}
|
|
191
247
|
|
|
248
|
+
/** Форматує короткий ETA; caller передає лише вже перевірену обережну оцінку. */
|
|
249
|
+
function formatEta(ms) {
|
|
250
|
+
const seconds = Math.max(1, Math.round(ms / 1000))
|
|
251
|
+
if (seconds < 60) return `${seconds} с`
|
|
252
|
+
return `${Math.ceil(seconds / 60)} хв`
|
|
253
|
+
}
|
|
254
|
+
|
|
192
255
|
/**
|
|
193
256
|
* UI очікування для хуків withLock: реєструє процес у черзі, на кожен tick
|
|
194
257
|
* рендерить рядок стану (TTY — перемальовування одного рядка на stderr;
|
|
@@ -213,13 +276,15 @@ function createWaitUi(opts = {}) {
|
|
|
213
276
|
} catch {
|
|
214
277
|
// best-effort: без реєстрації процес просто не видно у списку черги
|
|
215
278
|
}
|
|
279
|
+
// non-TTY мусить сказати про очікування одразу, а не через перший інтервал poll-а.
|
|
280
|
+
lastAppendAt = 0
|
|
216
281
|
},
|
|
217
282
|
onWaitTick: owner => {
|
|
218
283
|
const line = renderWaitLine(owner, listQueue(queueDir), readOwnerProgress(owner.pid, progressFile))
|
|
219
284
|
if (isTTY) {
|
|
220
285
|
// \r + ANSI clear-line: один рядок, що перемальовується на кожен tick
|
|
221
286
|
log(`\r\u{1B}[2K${line}`)
|
|
222
|
-
} else if (Date.now() - lastAppendAt >= NON_TTY_WAIT_LOG_INTERVAL_MS) {
|
|
287
|
+
} else if (lastAppendAt === 0 || Date.now() - lastAppendAt >= NON_TTY_WAIT_LOG_INTERVAL_MS) {
|
|
223
288
|
lastAppendAt = Date.now()
|
|
224
289
|
log(`${line}\n`)
|
|
225
290
|
}
|
|
@@ -22,7 +22,7 @@ const BAR_WIDTH = 20
|
|
|
22
22
|
* Рендерить один рядок прогресу — спільний формат для TTY-бара цього reporter-а
|
|
23
23
|
* та для прогресу чужого прогону, який показує процес у черзі
|
|
24
24
|
* (`lint-lock.mjs` читає знімок зі стан-файлу і показує його тим самим рядком).
|
|
25
|
-
* @param {{ done: number, total: number, found: number, fixed: number, current: string, unitLabel?: string, withFixed?: boolean }} snap знімок прогресу
|
|
25
|
+
* @param {{ done: number, total: number, found: number, fixed: number, current: string, unitLabel?: string, withFixed?: boolean, detail?: { label: string, done: number, total: number, current: string } | null }} snap знімок прогресу
|
|
26
26
|
* @returns {string} готовий рядок бара
|
|
27
27
|
*/
|
|
28
28
|
export function renderProgressLine(snap) {
|
|
@@ -31,7 +31,10 @@ export function renderProgressLine(snap) {
|
|
|
31
31
|
const filled = Math.round(progress * BAR_WIDTH)
|
|
32
32
|
const bar = '█'.repeat(filled) + '░'.repeat(BAR_WIDTH - filled)
|
|
33
33
|
const ticker = (snap.withFixed ?? true) ? ` · знайдено ${snap.found} · виправлено ${snap.fixed}` : ''
|
|
34
|
-
|
|
34
|
+
const sub = snap.detail
|
|
35
|
+
? ` · ${snap.detail.label} ${snap.detail.done}/${snap.detail.total} · ${snap.detail.current}`
|
|
36
|
+
: ''
|
|
37
|
+
return `[${bar}] ${snap.done}/${snap.total} ${unitLabel}${ticker} · ${snap.current}${sub}`
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
/**
|
|
@@ -61,6 +64,7 @@ function formatBar(_options, params, payload) {
|
|
|
61
64
|
* @property {(label: string, tier?: string) => void} concernStart оновити суфікс поточної одиниці
|
|
62
65
|
* @property {(key: string, count: number) => void} detectSnapshot знімок detect/re-detect (кількість порушень)
|
|
63
66
|
* @property {(key: string) => void} concernDone одиницю оброблено (закриту чи ні) — бар +1
|
|
67
|
+
* @property {(detail: { label: string, done: number, total: number, current: string } | null) => void} detail опублікувати деталізацію поточного concern-а
|
|
64
68
|
* @property {() => { done: number, total: number, found: number, fixed: number }} summary поточні лічильники
|
|
65
69
|
* @property {() => void} stop фінальний render і звільнення TTY-рядка
|
|
66
70
|
*/
|
|
@@ -89,6 +93,8 @@ export function createProgressReporter(opts) {
|
|
|
89
93
|
const counters = new Map()
|
|
90
94
|
let done = 0
|
|
91
95
|
let current = '…'
|
|
96
|
+
/** @type {{ label: string, done: number, total: number, current: string } | null} */
|
|
97
|
+
let detail = null
|
|
92
98
|
|
|
93
99
|
/**
|
|
94
100
|
* Пер-ключ стан лічильників (лениве створення).
|
|
@@ -139,7 +145,7 @@ export function createProgressReporter(opts) {
|
|
|
139
145
|
/** Перемальовує бар актуальним payload-ом (TTY) і публікує знімок назовні. */
|
|
140
146
|
function redraw() {
|
|
141
147
|
const { found, fixed } = tally()
|
|
142
|
-
opts.onUpdate?.({ done, total, found, fixed, current })
|
|
148
|
+
opts.onUpdate?.({ done, total, found, fixed, current, detail })
|
|
143
149
|
if (!bar) return
|
|
144
150
|
bar.update(done, { unitLabel, withFixed, found, fixed, current })
|
|
145
151
|
}
|
|
@@ -153,6 +159,12 @@ export function createProgressReporter(opts) {
|
|
|
153
159
|
|
|
154
160
|
concernStart: (label, tier) => {
|
|
155
161
|
current = tier ? `${label} (${tier})` : label
|
|
162
|
+
detail = null
|
|
163
|
+
redraw()
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
detail: next => {
|
|
167
|
+
detail = next
|
|
156
168
|
redraw()
|
|
157
169
|
},
|
|
158
170
|
|
|
@@ -527,7 +527,15 @@ async function buildPlan({
|
|
|
527
527
|
*/
|
|
528
528
|
async function runPlanItem({ entry, files }, { cwd, verbose, progress, log, signal }) {
|
|
529
529
|
/** @type {LintContext} */
|
|
530
|
-
const ctx = {
|
|
530
|
+
const ctx = {
|
|
531
|
+
cwd,
|
|
532
|
+
ruleId: entry.ruleId,
|
|
533
|
+
concernId: entry.concern.name,
|
|
534
|
+
files,
|
|
535
|
+
verbose,
|
|
536
|
+
signal,
|
|
537
|
+
reportProgressDetail: progress?.detail
|
|
538
|
+
}
|
|
531
539
|
const key = `${entry.ruleId}/${entry.concern.name}`
|
|
532
540
|
progress?.concernStart(key)
|
|
533
541
|
if (verbose) {
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
* @property {AbortSignal} [signal] сигнал скасування — лише у parallel lane `detectAll()`
|
|
25
25
|
* (`N_RULES_LINT_CONCURRENCY>1`, ADR 260716-1354); async-детектори (`spawnAsync`-based)
|
|
26
26
|
* прокидають його далі, щоб перерватись при infrastructure-помилці іншого concern-а
|
|
27
|
+
* @property {(detail: { label: string, done: number, total: number, current: string } | null) => void} [reportProgressDetail]
|
|
28
|
+
* optional read-only live detail поточного concern-а для full-lint observer-а
|
|
27
29
|
*/
|
|
28
30
|
|
|
29
31
|
/**
|