@7n/rules 1.4.1 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/bin/n-rules.js +65 -17
  3. package/package.json +11 -1
  4. package/rules/abie/clean_merged_ignore_branches/concern.json +3 -0
  5. package/rules/docker/lint_docker_yml/concern.json +3 -0
  6. package/rules/js/lint_js_yml/concern.json +3 -0
  7. package/rules/k8s/lint_k8s_yml/concern.json +3 -0
  8. package/rules/npm-module/npm_publish_yml/concern.json +3 -0
  9. package/rules/php/lint_php_yml/concern.json +3 -0
  10. package/rules/python/lint_python_yml/concern.json +3 -0
  11. package/rules/rust/lint_rust_yml/concern.json +3 -0
  12. package/rules/rust/toolchain_cache/concern.json +10 -1
  13. package/rules/security/lint_security_yml/concern.json +3 -0
  14. package/rules/style/lint_style_yml/concern.json +3 -0
  15. package/rules/tauri/main.mdc +1 -1
  16. package/rules/tauri/release/release.mdc +15 -0
  17. package/rules/text/lint_text/concern.json +3 -0
  18. package/schemas/concern.json +86 -16
  19. package/schemas/n-rules.json +11 -1
  20. package/schemas/v8r-catalog.json +123 -27
  21. package/scripts/auto-rules.mjs +47 -6
  22. package/scripts/docs/auto-rules.md +3 -1
  23. package/scripts/docs/skills-cli.md +3 -2
  24. package/scripts/docs/upgrade-n-rules-and-install.md +3 -1
  25. package/scripts/lib/acp-runner.mjs +174 -0
  26. package/scripts/lib/concern-meta.mjs +8 -1
  27. package/scripts/lib/docs/acp-runner.md +58 -0
  28. package/scripts/lib/docs/concern-meta.md +1 -1
  29. package/scripts/lib/docs/read-n-rules-config-lite.md +3 -1
  30. package/scripts/lib/docs/resolve-plugins.md +17 -0
  31. package/scripts/lib/docs/run-conftest-batch.md +3 -1
  32. package/scripts/lib/lint-surface/docs/policy-lint-adapter.md +3 -1
  33. package/scripts/lib/lint-surface/docs/run-detectors.md +5 -1
  34. package/scripts/lib/lint-surface/policy-lint-adapter.mjs +2 -0
  35. package/scripts/lib/lint-surface/run-detectors.mjs +72 -5
  36. package/scripts/lib/read-n-rules-config-lite.mjs +5 -3
  37. package/scripts/lib/resolve-plugins.mjs +248 -0
  38. package/scripts/lib/run-conftest-batch.mjs +3 -1
  39. package/scripts/skills-cli.mjs +30 -52
  40. package/scripts/upgrade-n-rules-and-install.mjs +5 -4
  41. package/rules/ga/clean_ga_workflows/clean_ga_workflows.mdc +0 -20
  42. package/rules/ga/clean_ga_workflows/clean_ga_workflows.rego +0 -112
  43. package/rules/ga/clean_ga_workflows/concern.json +0 -10
  44. package/rules/ga/clean_ga_workflows/docs/fix-clean_ga_workflows.md +0 -33
  45. package/rules/ga/clean_ga_workflows/docs/index.md +0 -9
  46. package/rules/ga/clean_ga_workflows/fix-clean_ga_workflows.mjs +0 -8
  47. package/rules/ga/clean_ga_workflows/template/clean-ga-workflows.yml.snippet.yml +0 -26
  48. package/rules/ga/clean_merged_branch/clean_merged_branch.mdc +0 -24
  49. package/rules/ga/clean_merged_branch/clean_merged_branch.rego +0 -163
  50. package/rules/ga/clean_merged_branch/concern.json +0 -10
  51. package/rules/ga/clean_merged_branch/docs/fix-clean_merged_branch.md +0 -27
  52. package/rules/ga/clean_merged_branch/docs/index.md +0 -9
  53. package/rules/ga/clean_merged_branch/fix-clean_merged_branch.mjs +0 -8
  54. package/rules/ga/clean_merged_branch/template/clean-merged-branch.yml.snippet.yml +0 -37
  55. package/rules/ga/docs/index.md +0 -11
  56. package/rules/ga/git_ai/concern.json +0 -11
  57. package/rules/ga/git_ai/docs/fix-git_ai.md +0 -30
  58. package/rules/ga/git_ai/docs/index.md +0 -9
  59. package/rules/ga/git_ai/fix-git_ai.mjs +0 -5
  60. package/rules/ga/git_ai/git_ai.mdc +0 -19
  61. package/rules/ga/git_ai/git_ai.rego +0 -76
  62. package/rules/ga/git_ai/template/git-ai.yml.snippet.yml +0 -30
  63. package/rules/ga/lint_ga/concern.json +0 -10
  64. package/rules/ga/lint_ga/docs/fix-lint_ga.md +0 -32
  65. package/rules/ga/lint_ga/docs/index.md +0 -9
  66. package/rules/ga/lint_ga/fix-lint_ga.mjs +0 -5
  67. package/rules/ga/lint_ga/lint_ga.mdc +0 -21
  68. package/rules/ga/lint_ga/lint_ga.rego +0 -132
  69. package/rules/ga/lint_ga/template/lint-ga.yml.snippet.yml +0 -41
  70. package/rules/ga/main.json +0 -1
  71. package/rules/ga/main.mdc +0 -17
  72. package/rules/ga/vscode_extensions/concern.json +0 -11
  73. package/rules/ga/vscode_extensions/docs/fix-vscode_extensions.md +0 -25
  74. package/rules/ga/vscode_extensions/docs/index.md +0 -11
  75. package/rules/ga/vscode_extensions/fix-vscode_extensions.mjs +0 -1
  76. package/rules/ga/vscode_extensions/template/extensions.json.snippet.json +0 -1
  77. package/rules/ga/vscode_extensions/vscode_extensions.mdc +0 -9
  78. package/rules/ga/vscode_extensions/vscode_extensions.rego +0 -18
  79. package/rules/ga/vscode_settings/concern.json +0 -11
  80. package/rules/ga/vscode_settings/docs/fix-vscode_settings.md +0 -26
  81. package/rules/ga/vscode_settings/docs/index.md +0 -9
  82. package/rules/ga/vscode_settings/fix-vscode_settings.mjs +0 -5
  83. package/rules/ga/vscode_settings/template/settings.json.snippet.json +0 -1
  84. package/rules/ga/vscode_settings/vscode_settings.mdc +0 -9
  85. package/rules/ga/vscode_settings/vscode_settings.rego +0 -22
  86. package/rules/ga/workflow_common/concern.json +0 -9
  87. package/rules/ga/workflow_common/template/uses-min-versions.snippet.json +0 -4
  88. package/rules/ga/workflow_common/workflow_common.mdc +0 -18
  89. package/rules/ga/workflow_common/workflow_common.rego +0 -288
  90. package/rules/ga/workflows/concern.json +0 -8
  91. package/rules/ga/workflows/docs/fix-workflows.md +0 -37
  92. package/rules/ga/workflows/docs/main.md +0 -38
  93. package/rules/ga/workflows/fix-workflows.mjs +0 -212
  94. package/rules/ga/workflows/main.mjs +0 -433
  95. package/rules/ga/workflows/workflows.mdc +0 -33
  96. package/rules/ga/zizmor_yml/concern.json +0 -11
  97. package/rules/ga/zizmor_yml/docs/fix-zizmor_yml.md +0 -28
  98. package/rules/ga/zizmor_yml/docs/index.md +0 -9
  99. package/rules/ga/zizmor_yml/fix-zizmor_yml.mjs +0 -3
  100. package/rules/ga/zizmor_yml/template/zizmor.yml.snippet.yml +0 -5
  101. package/rules/ga/zizmor_yml/zizmor_yml.mdc +0 -9
  102. package/rules/ga/zizmor_yml/zizmor_yml.rego +0 -27
@@ -0,0 +1,58 @@
1
+ ---
2
+ type: JS Module
3
+ title: acp-runner.mjs
4
+ resource: npm/scripts/lib/acp-runner.mjs
5
+ ---
6
+
7
+ ## Огляд
8
+
9
+ Модуль виконує скіл через зовнішнього ACP-агента (Agent Client Protocol,
10
+ agentclientprotocol.com) — JSON-RPC поверх stdio замість сирого piping
11
+ `stdin`/`stdout` у CLI. Замінює колишній `runLlmCli` (`spawnSync` + `-p`/`exec -`)
12
+ для раннерів `cursor`/`codex`/`claude` у `skills-cli.mjs`.
13
+
14
+ `cursor` запускається через нативний ACP-режим `cursor-agent acp` (зовнішній
15
+ бінарник у PATH). `codex`/`claude` — через офіційні бандловані адаптери
16
+ (`@agentclientprotocol/codex-acp`, `@agentclientprotocol/claude-agent-acp`), що
17
+ самі керують своїм рушієм — зовнішній бінарник `codex`/`claude` у PATH більше
18
+ не потрібен.
19
+
20
+ ## Поведінка
21
+
22
+ - `ACP_AGENT_COMMANDS` — команда запуску агента на провайдер: `cursor` — бінарник
23
+ у PATH; `codex`/`claude` — резолвляться з `bin`-запису бандлованого пакета-адаптера.
24
+ - `stopReasonToExitCode` — мапить ACP `StopReason` прогону на exit code: `end_turn` → `0`,
25
+ усе інше (`max_tokens`, `max_turn_requests`, `refusal`, `cancelled`) → `1`.
26
+ - `resolveAdapterBin` — резолвить абсолютний шлях до bin-файлу адаптера з його `package.json`.
27
+ - `pickAutoPermissionOptionId` — обирає `PermissionOption` без участі людини:
28
+ `allow_always` > `allow_once` > перша опція. Паритет із non-interactive `-p`/`exec -`
29
+ режимом — скіл є явною user-invocation, тож дозволи на tool-calls не питаються
30
+ інтерактивно.
31
+ - `AcpSkillClient` (internal) — ACP `Client` для скіл-раннера: `requestPermission`
32
+ автоапрувляє через `pickAutoPermissionOptionId`, `sessionUpdate` стрімить текстові
33
+ дельти (`agent_message_chunk`) у переданий `out`, `readTextFile`/`writeTextFile`
34
+ реалізовані напряму через `node:fs` (full-trust режим, без write-guard).
35
+ - `runAcpRunner` — підʼєднується до агента (`initialize` → `newSession` → `prompt`),
36
+ повертає exit code за `stopReasonToExitCode`; при stopReason ≠ `end_turn` додатково
37
+ логує причину через `logError`.
38
+
39
+ ## Публічний API
40
+
41
+ - `ACP_AGENT_COMMANDS` — таблиця команд запуску на провайдер.
42
+ - `stopReasonToExitCode(stopReason)` — `StopReason` → `0 | 1`.
43
+ - `resolveAdapterBin(adapterPackage)` — шлях до bin-файлу адаптера.
44
+ - `pickAutoPermissionOptionId(options)` — `optionId` автообраного варіанту дозволу.
45
+ - `runAcpRunner(kind, prompt, projectDir, logError, deps?)` — виконує скіл через
46
+ ACP-агента `kind` (`cursor`|`codex`|`claude`); `deps` — інжекти для тестів
47
+ (`acp`, `spawnFn`, `out`, `resolveAdapterBin`, `isBinaryInPath`).
48
+
49
+ ## Гарантії поведінки
50
+
51
+ - Permission-реквести від агента автоапрувляться без інтерактивного проміту —
52
+ парність із колишнім non-interactive `-p`/`exec -` режимом (full user-trust,
53
+ без write-guard, як і в `@7n/llm-lib/agent-skill`).
54
+ - `cursor` кидає, якщо `cursor-agent` відсутній у PATH (перевіряється до спавну
55
+ дочірнього процесу). `codex`/`claude` перевірки PATH не потребують — резолвляться
56
+ з установлених npm-залежностей.
57
+ - Дочірній процес агента завжди завершується (`child.kill()`) у `finally`, незалежно
58
+ від результату прогону.
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: concern-meta.mjs
4
4
  resource: npm/scripts/lib/concern-meta.mjs
5
5
  docgen:
6
- crc: 85be92d0
6
+ crc: 1675429b
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  tier: local-min
9
9
  score: 100
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: read-n-rules-config-lite.mjs
4
4
  resource: npm/scripts/lib/read-n-rules-config-lite.mjs
5
5
  docgen:
6
- crc: d7e64586
6
+ crc: b942af8d
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  ---
@@ -24,3 +24,5 @@ isRuleEnabled — визначає, чи діє певне правило на
24
24
  ## Гарантії поведінки
25
25
 
26
26
  - Read-only: не виконує операцій запису (ФС/БД).
27
+
28
+ **plugins:** LiteConfig містить `plugins` (масив npm-імен або `undefined`, якщо поля немає — тоді resolve-plugins робить автодетект).
@@ -0,0 +1,17 @@
1
+ ---
2
+ type: JS Module
3
+ title: resolve-plugins.mjs
4
+ resource: npm/scripts/lib/resolve-plugins.mjs
5
+ docgen:
6
+ crc: 4fbb42be
7
+ ---
8
+
9
+ Резолв плагінів @7n/rules: визначає, які пакети-плагіни активні у проєкті, де їхні `rules/`-каталоги, які capabilities вони надають і які handlers надають.
10
+
11
+ Джерело правди — поле `plugins: string[]` у `.n-rules.json`; воно завжди перекриває автодетект, а явний порожній масив означає «плагіни вимкнено». Якщо поля немає, `detectPluginsFromRepo` шукає файлові сигнали: наявність yml у `.github/workflows/` дає `@7n/rules-ci-github`, файл `azure-pipelines.yml` у корені — `@7n/rules-ci-azure` (реєстр `KNOWN_CI_PLUGINS`). Лише коли файлових сигналів немає, вмикається fallback за `repository.url` кореневого package.json (`github.com` → github, `dev.azure.com`/`visualstudio.com` → azure). Обидва сигнали дають обидва плагіни, жодного — порожній список.
12
+
13
+ `ensurePluginInstalled` ставить відсутній плагін через `bun add -d` (пакет стає devDependency — зміна видима у diff). Будь-який фейл установки (offline, пакет не опублікований) — warning і graceful skip, ніколи не hard-fail: лінт і синк мають працювати без мережі.
14
+
15
+ `resolvePlugins(projectRoot, config, options)` — головна функція: повертає масив `{name, packageRoot, rulesDir, manifest}` доступних плагінів з кешем на процес. `options.allowInstall: false` — hot-path режим (hook, lint): лише вже встановлені пакети, без `bun add`; `options.quiet: true` глушить warning-и (hook викликається на кожен файл). Плагін без каталогу `rules/` пропускається.
16
+
17
+ Маніфест — блок `"n-rules"` у package.json плагіна: `capabilities` (масив рядків на кшталт `ci:github`, живлять гейт концернів `requires.capability`) і `contributes.handlers` (мапа extension-point → відносний шлях модуля). `getActiveCapabilities` агрегує capabilities усіх плагінів у Set; `getHandlers(point)` повертає абсолютні шляхи модулів-обробників (v1 — лише API, споживачі handlers з'являться у v2). `resolveRulesDirs` віддає впорядковані джерела правил: ядро завжди перше (його правила й концерни виграють колізії), далі плагіни у порядку списку. `clearPluginResolveCache` скидає кеш (для тестів).
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: run-conftest-batch.mjs
4
4
  resource: npm/scripts/lib/run-conftest-batch.mjs
5
5
  docgen:
6
- crc: c581ce48
6
+ crc: 1ae29233
7
7
  ---
8
8
 
9
9
  Файл запускає `conftest test` на заданому списку файлів, виявляючи порушення правил, визначених у Rego-полісіях. Він використовується для автоматизованої перевірки конфігураційних файлів на відповідність заданим вимогам. Результати перевірки повертаються у структурованому вигляді, що дозволяє інтегрувати результати в інші процеси валідації.
@@ -28,3 +28,5 @@ runConftestBatch: Запускає `conftest test` для заданого сп
28
28
  - Не використовує кешування.
29
29
  - Не перехоплює помилки, а завершує роботу з помилкою.
30
30
  - Не має взаємодії з мережею.
31
+
32
+ **policyDirAbs:** опційний абсолютний шлях до policy-теки (правила з плагінів поза вбудованим rules/); за наявності має пріоритет над `policyDirRel`.
@@ -3,7 +3,7 @@ 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: c25338e7
6
+ crc: facc5e07
7
7
  model: openai-codex/gpt-5.5
8
8
  tier: cloud-avg
9
9
  score: 80
@@ -42,3 +42,5 @@ docgen:
42
42
  ## Гарантії поведінки
43
43
 
44
44
  - Read-only: не виконує операцій запису (ФС/БД).
45
+
46
+ **Плагіни:** у `runConftestBatch` передається `policyDirAbs: cfg.policyDir` — policy-тека concern-а може жити поза вбудованим rules/ ядра.
@@ -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: 984cc5be
6
+ crc: e8a0ec47
7
7
  model: omlx/gemma-4-e4b-it-OptiQ-4bit
8
8
  score: 100
9
9
  issues: judge:inaccurate:0.98
@@ -30,3 +30,7 @@ detectAll виконує прохід лінтера у режим детекц
30
30
  ## Гарантії поведінки
31
31
 
32
32
  - Read-only: не виконує операцій запису (ФС/БД).
33
+
34
+ **Multi-dir (плагіни):** `effectiveRulesDirs` додає rules-каталоги плагінів з `.n-rules.json` (hot-path: без install, quiet); `readLintConcernsByRuleMulti` зливає концерни за іменем (перший власник виграє) — плагін може додавати концерни до правила ядра (mixin).
35
+
36
+ **Capability-гейт:** `filterByCapabilities` відкидає концерни з незадоволеним `requires.capability` (capabilities надають встановлені плагіни через маніфест `n-rules.capabilities`; явний `opts.capabilities` у тестах перекриває резолв).
@@ -92,6 +92,8 @@ export async function evaluatePolicyConcern(ctx, cfg) {
92
92
  const templateData = await resolveConcernTemplateData(cfg.policyDir, { files: cfg.files })
93
93
  const denies = runConftestBatch({
94
94
  policyDirRel: `${ruleId}/${concernId}`,
95
+ // Абсолютний шлях теки concern-а — правило може жити поза вбудованим rules/ ядра (плагін).
96
+ policyDirAbs: cfg.policyDir,
95
97
  namespace,
96
98
  files,
97
99
  templateData
@@ -17,6 +17,7 @@ import picomatch from 'picomatch'
17
17
  import { listConcerns } from '../concern-meta.mjs'
18
18
  import { collectChangedFilesSince, resolveChangedBase } from '../changed-files.mjs'
19
19
  import { readNRulesConfigLite, isRuleEnabled } from '../read-n-rules-config-lite.mjs'
20
+ import { getActiveCapabilities, resolvePlugins } from '../resolve-plugins.mjs'
20
21
  import { runConcernDetector, DetectorError } from './detect.mjs'
21
22
  import { renderViolations, renderDiagnostics } from './render.mjs'
22
23
  import { createProgressReporter } from './progress.mjs'
@@ -83,6 +84,71 @@ async function readLintConcernsByRule(rulesDir) {
83
84
  return out
84
85
  }
85
86
 
87
+ /**
88
+ * Rules-каталоги прогону: явний `opts.rulesDirs`, або базовий (`opts.rulesDir` ?? вбудований)
89
+ * плюс каталоги плагінів з `.n-rules.json` (hot-path: без install і без warning-шуму).
90
+ * @param {{ rulesDirs?: string[], rulesDir?: string, cwd: string }} opts опції прогону.
91
+ * @returns {Promise<string[]>} упорядковані rules-каталоги (ядро перше).
92
+ */
93
+ async function effectiveRulesDirs(opts) {
94
+ if (Array.isArray(opts.rulesDirs) && opts.rulesDirs.length > 0) return opts.rulesDirs
95
+ const base = opts.rulesDir ?? DEFAULT_RULES_DIR
96
+ const config = await readNRulesConfigLite(opts.cwd)
97
+ const plugins = resolvePlugins(opts.cwd, { plugins: config.plugins }, { allowInstall: false, quiet: true })
98
+ return [base, ...plugins.map(p => p.rulesDir)]
99
+ }
100
+
101
+ /**
102
+ * Відкидає концерни з незадоволеним `requires.capability`: capability надають
103
+ * встановлені плагіни (маніфест `n-rules.capabilities`). Явний `opts.capabilities`
104
+ * (тести) перекриває резолв.
105
+ * @param {Record<string, ConcernMeta[]>} byRule concerns за rule-id.
106
+ * @param {{ capabilities?: Iterable<string>, cwd: string }} opts опції прогону.
107
+ * @returns {Promise<Record<string, ConcernMeta[]>>} відфільтровані concerns.
108
+ */
109
+ async function filterByCapabilities(byRule, opts) {
110
+ let caps
111
+ if (opts.capabilities) {
112
+ caps = new Set(opts.capabilities)
113
+ } else {
114
+ const config = await readNRulesConfigLite(opts.cwd)
115
+ caps = getActiveCapabilities(opts.cwd, { plugins: config.plugins }, { allowInstall: false, quiet: true })
116
+ }
117
+ /** @type {Record<string, ConcernMeta[]>} */
118
+ const out = {}
119
+ for (const [ruleId, concerns] of Object.entries(byRule)) {
120
+ const kept = concerns.filter(c => c.requiresCapability === undefined || caps.has(c.requiresCapability))
121
+ if (kept.length > 0) out[ruleId] = kept
122
+ }
123
+ return out
124
+ }
125
+
126
+ /**
127
+ * Мердж concerns кількох rules-каталогів: правила зливаються за id, концерни — за іменем
128
+ * (перший власник виграє: ядро → плагіни у порядку списку). Плагін може ДОДАВАТИ концерни
129
+ * до правила ядра (mixin), але не перекривати наявні.
130
+ * @param {string[]} rulesDirs упорядковані rules-каталоги.
131
+ * @returns {Promise<Record<string, ConcernMeta[]>>} об'єднані concerns за rule-id.
132
+ */
133
+ async function readLintConcernsByRuleMulti(rulesDirs) {
134
+ /** @type {Record<string, ConcernMeta[]>} */
135
+ const merged = {}
136
+ for (const dir of rulesDirs) {
137
+ const byRule = await readLintConcernsByRule(dir)
138
+ for (const [ruleId, concerns] of Object.entries(byRule)) {
139
+ if (!(ruleId in merged)) {
140
+ merged[ruleId] = [...concerns]
141
+ continue
142
+ }
143
+ const seen = new Set(merged[ruleId].map(c => c.name))
144
+ for (const c of concerns) {
145
+ if (!seen.has(c.name)) merged[ruleId].push(c)
146
+ }
147
+ }
148
+ }
149
+ return merged
150
+ }
151
+
86
152
  /**
87
153
  * Активні rule-id з `.n-rules.json` (для delta/full режимів).
88
154
  * @param {Record<string, ConcernMeta[]>} byRule concerns згруповані за rule-id.
@@ -111,7 +177,8 @@ function sortEntries(entries) {
111
177
  * Будує план прогону для заданих опцій (discovery + scope-table).
112
178
  * Спільне джерело для detect-only і fix-pipeline.
113
179
  * @param {object} opts опції прогону.
114
- * @param {string} opts.rulesDir корінь із правилами.
180
+ * @param {string} [opts.rulesDir] базовий корінь із правилами (дефолт — вбудований).
181
+ * @param {string[]} [opts.rulesDirs] явні rules-каталоги (ядро + плагіни); без них — базовий + плагіни з конфігу.
115
182
  * @param {string} opts.cwd робоча директорія прогону.
116
183
  * @param {boolean} [opts.full] whole-repo режим (усі enabled-concerns).
117
184
  * @param {string[]} [opts.rules] scoped rule-id (порожній → delta/full).
@@ -119,7 +186,7 @@ function sortEntries(entries) {
119
186
  * @returns {Promise<PlanItem[]>} впорядкований план прогону.
120
187
  */
121
188
  export async function buildDetectPlan(opts) {
122
- const byRule = await readLintConcernsByRule(opts.rulesDir ?? DEFAULT_RULES_DIR)
189
+ const byRule = await filterByCapabilities(await readLintConcernsByRuleMulti(await effectiveRulesDirs(opts)), opts)
123
190
  return buildPlan({
124
191
  byRule,
125
192
  full: opts.full === true,
@@ -231,7 +298,8 @@ async function buildPlan({ byRule, full, rules, explicitFiles, cwd }) {
231
298
  /**
232
299
  * Запускає detect-only прохід. Повертає всі violations і похідний exitCode.
233
300
  * @param {object} opts опції прогону.
234
- * @param {string} opts.rulesDir корінь із правилами.
301
+ * @param {string} [opts.rulesDir] базовий корінь із правилами (дефолт — вбудований).
302
+ * @param {string[]} [opts.rulesDirs] явні rules-каталоги (ядро + плагіни); без них — базовий + плагіни з конфігу.
235
303
  * @param {string} opts.cwd робоча директорія прогону.
236
304
  * @param {boolean} [opts.full] whole-repo режим.
237
305
  * @param {string[]} [opts.rules] scoped rule-id (порожній → delta/full).
@@ -243,7 +311,6 @@ async function buildPlan({ byRule, full, rules, explicitFiles, cwd }) {
243
311
  * @returns {Promise<{ violations: LintViolation[], exitCode: 0|1|2, ran: LintEntry[] }>} violations, exitCode і виконані entries.
244
312
  */
245
313
  export async function detectAll(opts) {
246
- const rulesDir = opts.rulesDir ?? DEFAULT_RULES_DIR
247
314
  const { cwd } = opts
248
315
  const full = opts.full === true
249
316
  const rules = Array.isArray(opts.rules) ? opts.rules : []
@@ -251,7 +318,7 @@ export async function detectAll(opts) {
251
318
  const verbose = opts.verbose === true
252
319
  const baseLog = opts.log ?? (s => process.stdout.write(s))
253
320
 
254
- const byRule = await readLintConcernsByRule(rulesDir)
321
+ const byRule = await filterByCapabilities(await readLintConcernsByRuleMulti(await effectiveRulesDirs(opts)), opts)
255
322
  const plan = await buildPlan({ byRule, full, rules, explicitFiles, cwd })
256
323
 
257
324
  // Detect-only бар — ЛИШЕ в TTY (без тикера «виправлено»). У не-TTY (hooks, CI-gate,
@@ -22,6 +22,7 @@ const LEGACY_CONFIG_FILE = '.n-cursor.json'
22
22
  * @property {boolean} exists чи існує .n-rules.json (або legacy .n-rules.json) у поточному каталозі
23
23
  * @property {string[]} rules id правил з whitelist (порожній якщо файл відсутній)
24
24
  * @property {string[]} disableRules id правил, явно вимкнених у `disable-rules`
25
+ * @property {string[] | undefined} plugins npm-пакети-плагіни з конфігу; undefined — поля немає (→ автодетект у resolve-plugins)
25
26
  */
26
27
 
27
28
  /**
@@ -34,16 +35,17 @@ export async function readNRulesConfigLite(cwd = process.cwd()) {
34
35
  configPath = join(cwd, LEGACY_CONFIG_FILE)
35
36
  }
36
37
  if (!existsSync(configPath)) {
37
- return { exists: false, rules: [], disableRules: [] }
38
+ return { exists: false, rules: [], disableRules: [], plugins: undefined }
38
39
  }
39
40
  const raw = await readFile(configPath, 'utf8')
40
- /** @type {{ rules?: unknown, ['disable-rules']?: unknown }} */
41
+ /** @type {{ rules?: unknown, ['disable-rules']?: unknown, plugins?: unknown }} */
41
42
  const parsed = JSON.parse(raw)
42
43
  const rules = Array.isArray(parsed.rules) ? parsed.rules.filter(r => typeof r === 'string') : []
43
44
  const disableRules = Array.isArray(parsed['disable-rules'])
44
45
  ? parsed['disable-rules'].filter(r => typeof r === 'string')
45
46
  : []
46
- return { exists: true, rules, disableRules }
47
+ const plugins = Array.isArray(parsed.plugins) ? parsed.plugins.filter(p => typeof p === 'string') : undefined
48
+ return { exists: true, rules, disableRules, plugins }
47
49
  }
48
50
 
49
51
  /**
@@ -0,0 +1,248 @@
1
+ /**
2
+ * Резолв плагінів `@7n/rules`: які пакети-плагіни активні у проєкті, де їхні `rules/`,
3
+ * які capabilities вони дають і які handlers надають.
4
+ *
5
+ * Джерело правди — поле `plugins: string[]` у `.n-rules.json` (завжди перекриває автодетект;
6
+ * явний `[]` = «плагіни вимкнено»). Якщо поля немає — `detectPluginsFromRepo`: файлові сигнали
7
+ * (`.github/workflows/*.yml` → `@7n/rules-ci-github`; `azure-pipelines.yml` → `@7n/rules-ci-azure`),
8
+ * а без них — fallback за `repository.url` кореневого package.json (`github.com` / `dev.azure.com`).
9
+ *
10
+ * Установка: `ensurePluginInstalled` — плагін стає devDependency через `bun add -d` (bun сам
11
+ * резолвить актуальну версію; зміна видима у diff package.json). Фейл установки (offline,
12
+ * пакет ще не опублікований) — warning + graceful skip, ніколи не hard-fail: лінт/синк
13
+ * мають працювати без мережі. Hot-path (hook) НЕ встановлює — лише резолвить уже встановлені
14
+ * (`allowInstall: false`).
15
+ *
16
+ * Маніфест плагіна — блок `"n-rules"` у його package.json:
17
+ * `{ "capabilities": ["ci:github"], "contributes": { "rules": true, "handlers": { "<point>": "./mod.mjs" } } }`.
18
+ * `capabilities` живлять гейт концернів (`concern.json` → `requires.capability`);
19
+ * `handlers` — іменовані extension-points правил ядра (v1: лише API, споживачі — v2).
20
+ */
21
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
22
+ import { spawnSync } from 'node:child_process'
23
+ import { join, resolve } from 'node:path'
24
+
25
+ /** Відомі плагіни для автовизначення: сигнал у дереві репо → npm-пакет. */
26
+ export const KNOWN_CI_PLUGINS = Object.freeze({
27
+ github: '@7n/rules-ci-github',
28
+ azure: '@7n/rules-ci-azure'
29
+ })
30
+
31
+ const WORKFLOW_YML_RE = /\.ya?ml$/u
32
+ const GITHUB_URL_RE = /github\.com/iu
33
+ const AZURE_URL_RE = /dev\.azure\.com|visualstudio\.com/iu
34
+
35
+ /** Кеш резолву на процес: projectRoot → результат `resolvePlugins`. */
36
+ const RESOLVE_CACHE = new Map()
37
+
38
+ /**
39
+ * `repository.url` з кореневого package.json (string або {url}); null, якщо нема/нечитабельний.
40
+ * @param {string} projectRoot корінь репозиторію
41
+ * @returns {string | null} URL або null
42
+ */
43
+ function readRepositoryUrl(projectRoot) {
44
+ try {
45
+ const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'))
46
+ const repo = pkg?.repository
47
+ if (typeof repo === 'string') return repo
48
+ if (repo && typeof repo === 'object' && typeof repo.url === 'string') return repo.url
49
+ } catch {
50
+ /* нема package.json або битий JSON — сигналу нема */
51
+ }
52
+ return null
53
+ }
54
+
55
+ /**
56
+ * Чи є у `.github/workflows/` хоч один yml/yaml.
57
+ * @param {string} projectRoot корінь репозиторію
58
+ * @returns {boolean} true — GitHub Actions присутні
59
+ */
60
+ function hasGithubWorkflows(projectRoot) {
61
+ const dir = join(projectRoot, '.github', 'workflows')
62
+ if (!existsSync(dir)) return false
63
+ try {
64
+ return readdirSync(dir).some(name => WORKFLOW_YML_RE.test(name))
65
+ } catch {
66
+ return false
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Автодетект CI-плагінів за станом репозиторію.
72
+ * Файлові сигнали мають пріоритет; `repository.url` — лише коли файлових сигналів нема
73
+ * (свіже репо без CI-конфігів). Обидва сигнали → обидва плагіни; жодного → [].
74
+ * @param {string} projectRoot корінь репозиторію
75
+ * @returns {string[]} npm-імена плагінів
76
+ */
77
+ export function detectPluginsFromRepo(projectRoot) {
78
+ const out = []
79
+ if (hasGithubWorkflows(projectRoot)) out.push(KNOWN_CI_PLUGINS.github)
80
+ if (existsSync(join(projectRoot, 'azure-pipelines.yml'))) out.push(KNOWN_CI_PLUGINS.azure)
81
+ if (out.length > 0) return out
82
+
83
+ const url = readRepositoryUrl(projectRoot)
84
+ if (typeof url === 'string') {
85
+ if (GITHUB_URL_RE.test(url)) out.push(KNOWN_CI_PLUGINS.github)
86
+ if (AZURE_URL_RE.test(url)) out.push(KNOWN_CI_PLUGINS.azure)
87
+ }
88
+ return out
89
+ }
90
+
91
+ /**
92
+ * Список плагінів проєкту: явний `config.plugins` (включно з порожнім = вимкнено) або автодетект.
93
+ * @param {string} projectRoot корінь репозиторію
94
+ * @param {{ plugins?: unknown } | null | undefined} config розпарсений `.n-rules.json` (може бути відсутній)
95
+ * @returns {string[]} npm-імена плагінів
96
+ */
97
+ export function resolvePluginList(projectRoot, config) {
98
+ const declared = config?.plugins
99
+ if (Array.isArray(declared)) {
100
+ return declared.filter(p => typeof p === 'string' && p.trim() !== '')
101
+ }
102
+ return detectPluginsFromRepo(projectRoot)
103
+ }
104
+
105
+ /**
106
+ * Гарантує, що плагін встановлений: якщо `node_modules/<pkg>` нема — `bun add -d <pkg>`
107
+ * (дописує devDependency і ставить). Фейл — warning + false, без винятку.
108
+ * @param {string} projectRoot корінь репозиторію
109
+ * @param {string} packageName npm-ім'я плагіна
110
+ * @returns {boolean} true — пакет доступний у node_modules після виклику
111
+ */
112
+ export function ensurePluginInstalled(projectRoot, packageName) {
113
+ const installed = join(projectRoot, 'node_modules', packageName, 'package.json')
114
+ if (existsSync(installed)) return true
115
+ if (!existsSync(join(projectRoot, 'package.json'))) return false
116
+
117
+ const r = spawnSync('bun', ['add', '-d', packageName], { cwd: projectRoot, encoding: 'utf8', shell: false })
118
+ if (r.error || r.status !== 0) {
119
+ const reason = r.error ? r.error.message : `bun add exit ${r.status}`
120
+ console.warn(`⚠️ Плагін ${packageName} не встановився (${reason}) — пропускаю\n`)
121
+ return false
122
+ }
123
+ return existsSync(installed)
124
+ }
125
+
126
+ /**
127
+ * @typedef {object} ResolvedPlugin
128
+ * @property {string} name npm-ім'я пакета (`@7n/rules` для ядра)
129
+ * @property {string} packageRoot абсолютний корінь пакета
130
+ * @property {string} rulesDir абсолютний шлях до `rules/` пакета
131
+ * @property {{ capabilities: string[], contributes: { rules?: boolean, handlers?: Record<string, string> } }} manifest нормалізований блок `n-rules` з package.json плагіна
132
+ */
133
+
134
+ /**
135
+ * Маніфест плагіна з блоку `"n-rules"` його package.json (з дефолтами).
136
+ * @param {string} packageRoot корінь пакета
137
+ * @returns {ResolvedPlugin['manifest']} нормалізований маніфест
138
+ */
139
+ function readPluginManifest(packageRoot) {
140
+ /** @type {ResolvedPlugin['manifest']} */
141
+ const fallback = { capabilities: [], contributes: { rules: true, handlers: {} } }
142
+ try {
143
+ const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'))
144
+ const raw = pkg?.['n-rules']
145
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return fallback
146
+ const capabilities = Array.isArray(raw.capabilities) ? raw.capabilities.filter(c => typeof c === 'string') : []
147
+ const contributes = raw.contributes && typeof raw.contributes === 'object' ? raw.contributes : {}
148
+ const handlers =
149
+ contributes.handlers && typeof contributes.handlers === 'object' && !Array.isArray(contributes.handlers)
150
+ ? Object.fromEntries(Object.entries(contributes.handlers).filter(([, v]) => typeof v === 'string'))
151
+ : {}
152
+ return { capabilities, contributes: { rules: contributes.rules !== false, handlers } }
153
+ } catch {
154
+ return fallback
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Повний резолв плагінів проєкту (з кешем на процес).
160
+ * @param {string} projectRoot корінь репозиторію
161
+ * @param {{ plugins?: unknown } | null | undefined} config розпарсений `.n-rules.json`
162
+ * @param {{ allowInstall?: boolean, quiet?: boolean }} [options] `allowInstall:false` (hot-path hook/lint) —
163
+ * лише вже встановлені пакети, без `bun add`; `quiet` — без warning-ів (hook на кожен файл)
164
+ * @returns {ResolvedPlugin[]} доступні плагіни (без ядра)
165
+ */
166
+ export function resolvePlugins(projectRoot, config, options = {}) {
167
+ const root = resolve(projectRoot)
168
+ const names = resolvePluginList(root, config)
169
+ const cacheKey = `${root}${names.join(',')}${options.allowInstall !== false}`
170
+ const cached = RESOLVE_CACHE.get(cacheKey)
171
+ if (cached) return cached
172
+
173
+ /** @type {ResolvedPlugin[]} */
174
+ const out = []
175
+ for (const name of names) {
176
+ const packageRoot = join(root, 'node_modules', name)
177
+ const available =
178
+ existsSync(join(packageRoot, 'package.json')) ||
179
+ (options.allowInstall !== false && ensurePluginInstalled(root, name))
180
+ if (!available) {
181
+ if (options.allowInstall === false && options.quiet !== true) {
182
+ console.warn(`⚠️ Плагін ${name} не встановлений — пропускаю (запусти npx @7n/rules)\n`)
183
+ }
184
+ continue
185
+ }
186
+ const rulesDir = join(packageRoot, 'rules')
187
+ if (!existsSync(rulesDir)) {
188
+ if (options.quiet !== true) console.warn(`⚠️ Плагін ${name} без каталогу rules/ — пропускаю\n`)
189
+ continue
190
+ }
191
+ out.push({ name, packageRoot, rulesDir, manifest: readPluginManifest(packageRoot) })
192
+ }
193
+ RESOLVE_CACHE.set(cacheKey, out)
194
+ return out
195
+ }
196
+
197
+ /**
198
+ * Rules-каталоги для всіх поверхонь ядра: ядро першим (його правила/концерни виграють
199
+ * колізії), далі плагіни у порядку списку.
200
+ * @param {string} projectRoot корінь репозиторію
201
+ * @param {{ plugins?: unknown } | null | undefined} config розпарсений `.n-rules.json`
202
+ * @param {string} bundledRulesDir `rules/` встановленого/вбудованого ядра
203
+ * @param {{ allowInstall?: boolean }} [options] прокидається у `resolvePlugins`
204
+ * @returns {Array<{ name: string, rulesDir: string, packageRoot: string | null }>} джерела правил
205
+ */
206
+ export function resolveRulesDirs(projectRoot, config, bundledRulesDir, options = {}) {
207
+ const plugins = resolvePlugins(projectRoot, config, options)
208
+ return [
209
+ { name: '@7n/rules', rulesDir: bundledRulesDir, packageRoot: null },
210
+ ...plugins.map(p => ({ name: p.name, rulesDir: p.rulesDir, packageRoot: p.packageRoot }))
211
+ ]
212
+ }
213
+
214
+ /**
215
+ * Активні capabilities від усіх доступних плагінів (для гейта `requires.capability` у concern.json).
216
+ * @param {string} projectRoot корінь репозиторію
217
+ * @param {{ plugins?: unknown } | null | undefined} config розпарсений `.n-rules.json`
218
+ * @param {{ allowInstall?: boolean }} [options] прокидається у `resolvePlugins`
219
+ * @returns {Set<string>} набір capability-рядків (напр. `ci:github`)
220
+ */
221
+ export function getActiveCapabilities(projectRoot, config, options = {}) {
222
+ const caps = new Set()
223
+ for (const p of resolvePlugins(projectRoot, config, options)) {
224
+ for (const c of p.manifest.capabilities) caps.add(c)
225
+ }
226
+ return caps
227
+ }
228
+
229
+ /**
230
+ * Handlers для extension-point правила ядра (v1 — лише API; перший споживач — v2).
231
+ * @param {string} projectRoot корінь репозиторію
232
+ * @param {{ plugins?: unknown } | null | undefined} config розпарсений `.n-rules.json`
233
+ * @param {string} point ім'я extension-point (напр. `doc-files`)
234
+ * @returns {Array<{ pluginName: string, modulePath: string }>} абсолютні шляхи модулів-обробників
235
+ */
236
+ export function getHandlers(projectRoot, config, point) {
237
+ const out = []
238
+ for (const p of resolvePlugins(projectRoot, config, { allowInstall: false })) {
239
+ const rel = p.manifest.contributes.handlers[point]
240
+ if (typeof rel === 'string') out.push({ pluginName: p.name, modulePath: join(p.packageRoot, rel) })
241
+ }
242
+ return out
243
+ }
244
+
245
+ /** Скидає кеш резолву (для тестів). */
246
+ export function clearPluginResolveCache() {
247
+ RESOLVE_CACHE.clear()
248
+ }
@@ -38,6 +38,8 @@ const RULES_ROOT = join(PACKAGE_ROOT, 'rules')
38
38
  /**
39
39
  * @typedef {object} ConftestBatchOptions
40
40
  * @property {string} policyDirRel шлях до підкаталогу `npm/policy/...` (наприклад `abie/base_deployment_preem`)
41
+ * @property {string} [policyDirAbs] абсолютний шлях до теки policy-concern-а — для правил ПОЗА вбудованим
42
+ * `rules/` ядра (плагіни); за наявності має пріоритет над `policyDirRel`
41
43
  * @property {string} namespace повне імʼя rego-пакета (наприклад `abie.base_deployment_preem`)
42
44
  * @property {string[]} files список абсолютних шляхів файлів для перевірки (порожній — повертаємо порожньо)
43
45
  * @property {string[]} [extraArgs] додаткові аргументи для conftest (наприклад `--combine` для крос-документних правил)
@@ -73,7 +75,7 @@ export function runConftestBatch(opts) {
73
75
  const slash = opts.policyDirRel.indexOf('/')
74
76
  const ruleId = slash === -1 ? opts.policyDirRel : opts.policyDirRel.slice(0, slash)
75
77
  const sub = slash === -1 ? '' : opts.policyDirRel.slice(slash + 1)
76
- const policyAbs = sub ? join(RULES_ROOT, ruleId, sub) : join(RULES_ROOT, ruleId)
78
+ const policyAbs = opts.policyDirAbs ?? (sub ? join(RULES_ROOT, ruleId, sub) : join(RULES_ROOT, ruleId))
77
79
  if (!existsSync(policyAbs)) {
78
80
  throw new Error(`runConftestBatch: rego-каталог не знайдено: ${policyAbs}`)
79
81
  }