@7n/rules 1.32.2 → 1.33.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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.33.0] - 2026-07-20
4
+
5
+ ### Added
6
+
7
+ - Per-категорійний автодетект плагінів: явний непорожній plugins (лише з @7n/rules-`category`-*) домішує автодетектом категорії, яких у ньому немає
8
+
3
9
  ## [1.32.2] - 2026-07-20
4
10
 
5
11
  ### Changed
package/bin/n-rules.js CHANGED
@@ -365,9 +365,11 @@ async function readConfig(paths = {}) {
365
365
  }
366
366
  }
367
367
 
368
- // Плагіни: явне поле plugins конфігу або автодетект; sync-контекстз установкою devDep.
369
- // Результат детекту записуємо у конфіг (нижче), щоб hook/lint не залежали від детекту й мережі.
370
- const detectedPlugins = Array.isArray(rawConfig?.plugins) ? null : resolvePluginList(cwd(), rawConfig)
368
+ // Плагіни: явне поле plugins конфігу per-категорійним backfill автодетекту ADR
369
+ // 260719-2154) або повний автодетект, коли поля нема; sync-контекст з установкою devDep.
370
+ // Результат записуємо у конфіг (нижче), щоб hook/lint не залежали від детекту й мережі.
371
+ const declaredPlugins = Array.isArray(rawConfig?.plugins) ? rawConfig.plugins : null
372
+ const resolvedPlugins = resolvePluginList(cwd(), rawConfig)
371
373
  const rulesDirs = resolveRulesDirs(cwd(), rawConfig, bundledRulesDir).map(d => d.rulesDir)
372
374
  const { names: availableRules } = await aggregateRuleSources(rulesDirs)
373
375
  const availableSkills = await discoverBundledSkillNames(bundledSkillsDir)
@@ -442,9 +444,17 @@ async function readConfig(paths = {}) {
442
444
  if (merged['disable-skills']?.length) {
443
445
  normalized['disable-skills'] = merged['disable-skills']
444
446
  }
445
- if (!('plugins' in parsedConfig) && detectedPlugins && detectedPlugins.length > 0) {
446
- normalized.plugins = detectedPlugins
447
- console.log(`🔌 Автодетект плагінів: ${detectedPlugins.join(', ')} — записано у ${CONFIG_FILE}\n`)
447
+ if (!('plugins' in parsedConfig)) {
448
+ if (resolvedPlugins.length > 0) {
449
+ normalized.plugins = resolvedPlugins
450
+ console.log(`🔌 Автодетект плагінів: ${resolvedPlugins.join(', ')} — записано у ${CONFIG_FILE}\n`)
451
+ }
452
+ } else if (declaredPlugins && JSON.stringify(resolvedPlugins) !== JSON.stringify(declaredPlugins)) {
453
+ // per-категорійний backfill (resolvePluginList, ADR 260719-2154) додав плагін
454
+ // категорії, відсутньої в явному plugins — фіксуємо результат у конфізі.
455
+ normalized.plugins = resolvedPlugins
456
+ const added = resolvedPlugins.filter(p => !declaredPlugins.includes(p))
457
+ console.log(`🔌 Доповнено plugins у ${CONFIG_FILE} (${added.join(', ')}) — категорія не була задекларована\n`)
448
458
  }
449
459
  return sortConfigIdArrays(normalized)
450
460
  }
@@ -465,7 +475,7 @@ async function readConfig(paths = {}) {
465
475
  $schema: CONFIG_SCHEMA_URL,
466
476
  rules: autoDetectedRules.rules,
467
477
  skills: autoDetectedSkills.skills,
468
- ...(detectedPlugins && detectedPlugins.length > 0 && { plugins: detectedPlugins })
478
+ ...(resolvedPlugins.length > 0 && { plugins: resolvedPlugins })
469
479
  })
470
480
  await writeFile(configPath, `${JSON.stringify(defaultConfig, null, 2)}\n`, 'utf8')
471
481
  console.log(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@7n/rules",
3
- "version": "1.32.2",
3
+ "version": "1.33.0",
4
4
  "description": "CLI еталонних правил і skills (префікс n-): синк у репозиторій, дельта-lint, конформність",
5
5
  "keywords": [
6
6
  "cli",
@@ -3,7 +3,7 @@ type: JS Module
3
3
  title: resolve-plugins.mjs
4
4
  resource: npm/scripts/lib/resolve-plugins.mjs
5
5
  docgen:
6
- crc: fb58860d
6
+ crc: 2bac9b49
7
7
  ---
8
8
 
9
9
  Резолв плагінів @7n/rules: визначає, які пакети-плагіни активні у проєкті, де їхні `rules/`-каталоги, які capabilities вони надають і які handlers надають.
@@ -2,10 +2,16 @@
2
2
  * Резолв плагінів `@7n/rules`: які пакети-плагіни активні у проєкті, де їхні `rules/`,
3
3
  * які capabilities вони дають і які handlers надають.
4
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`).
5
+ * Джерело правди — поле `plugins: string[]` у `.n-rules.json`. Явний `[]` = «плагіни
6
+ * вимкнено» (автодетект не застосовується). Якщо поля немає взагалі повний автодетект
7
+ * (`detectPluginsFromRepo`). Якщо `plugins` непорожній і складається **виключно** з пакетів
8
+ * за конвенцією `@7n/rules-<category>-<name>` (напр. `ci`, `lang`) автодетект домішує
9
+ * лише ті категорії, яких у списку немає (ADR `260719-2154-per-category-автодетект-плагінів`);
10
+ * будь-який сторонній (не `@7n/rules-*`) пакет у списку вимикає backfill повністю — змішаний
11
+ * чи повністю кастомний список означає ручне керування, без сюрпризів. Файлові сигнали
12
+ * автодетекту: `.github/workflows/*.yml` → `@7n/rules-ci-github`; `azure-pipelines.yml` →
13
+ * `@7n/rules-ci-azure`, а без них — fallback за `repository.url` кореневого package.json
14
+ * (`github.com` / `dev.azure.com`).
9
15
  *
10
16
  * Установка: `ensurePluginInstalled` — плагін стає devDependency через `bun add -d` (bun сам
11
17
  * резолвить актуальну версію; зміна видима у diff package.json). Фейл установки (offline,
@@ -153,18 +159,91 @@ export function detectPluginsFromRepo(projectRoot) {
153
159
  return out
154
160
  }
155
161
 
162
+ /** Naming convention плагінів, які розпізнає автодетект: `@7n/rules-<category>-<name>`. */
163
+ const PLUGIN_CATEGORY_RE = /^@7n\/rules-([a-z0-9]+)-/u
164
+
165
+ /**
166
+ * Категорія плагіна за naming convention `@7n/rules-<category>-<name>` (напр. `ci`, `lang`).
167
+ * `null` — пакет поза цією конвенцією (сторонній/кастомний плагін); такий пакет ніколи не
168
+ * зʼявляється сам через автодетект і, якщо присутній у явному `config.plugins`, вимикає
169
+ * per-категорійний backfill для всього списку (див. `resolvePluginList`).
170
+ * @param {string} pkg npm-ім'я плагіна
171
+ * @returns {string | null} категорія або null
172
+ */
173
+ export function pluginCategory(pkg) {
174
+ const m = PLUGIN_CATEGORY_RE.exec(pkg)
175
+ return m ? m[1] : null
176
+ }
177
+
178
+ /** Усі категорії, які реально може повернути `detectPluginsFromRepo` — для short-circuit коли declared вже покриває все. */
179
+ const ALL_KNOWN_CATEGORIES = new Set(
180
+ [...Object.values(KNOWN_CI_PLUGINS), ...Object.values(KNOWN_LANG_PLUGINS).map(l => l.pkg)]
181
+ .map(pkg => pluginCategory(pkg))
182
+ .filter(c => c !== null)
183
+ )
184
+
185
+ /** Кеш `resolvePluginList` на процес: `root::declared-json` → результат (щоб не дублювати warning). */
186
+ const PLUGIN_LIST_CACHE = new Map()
187
+
156
188
  /**
157
- * Список плагінів проєкту: явний `config.plugins` (включно з порожнім = вимкнено) або автодетект.
189
+ * Список плагінів проєкту: явний `config.plugins` або автодетект.
190
+ *
191
+ * Явний `plugins` непорожній і складається **виключно** з пакетів `@7n/rules-<category>-*` —
192
+ * автодетект домішує лише категорії, відсутні в списку (ADR
193
+ * `260719-2154-per-category-автодетект-плагінів`); категорія, присутня хоч одним пакетом,
194
+ * лишається зафіксованою користувачем. Якщо `plugins` містить хоча б один сторонній
195
+ * (не `@7n/rules-*`) пакет — це сигнал ручного керування, backfill вимикається повністю
196
+ * (як і раніше: список повертається як є). Явний `[]` — «плагіни вимкнено», без backfill.
197
+ * Поле відсутнє взагалі — повний автодетект.
198
+ *
199
+ * Результат кешується на процес за `(projectRoot, declared)` — виклик з `resolvePlugins`
200
+ * (через `resolveRulesDirs` тощо) і прямий виклик у sync-CLI інакше дублювали б і файловий
201
+ * скан, і warning про backfill.
158
202
  * @param {string} projectRoot корінь репозиторію
159
203
  * @param {{ plugins?: unknown } | null | undefined} config розпарсений `.n-rules.json` (може бути відсутній)
204
+ * @param {{ quiet?: boolean }} [options] `quiet:true` — без warning-у про плагін, доданий автодетектом (hot-path);
205
+ * ігнорується при cache hit — warning друкується щонайбільше раз на `(root, declared)` за процес
160
206
  * @returns {string[]} npm-імена плагінів
161
207
  */
162
- export function resolvePluginList(projectRoot, config) {
208
+ export function resolvePluginList(projectRoot, config, options = {}) {
209
+ const root = resolve(projectRoot)
163
210
  const declared = config?.plugins
164
- if (Array.isArray(declared)) {
165
- return declared.filter(p => typeof p === 'string' && p.trim() !== '')
211
+ const cacheKey = `${root}::${Array.isArray(declared) ? JSON.stringify(declared) : '∅'}`
212
+ const cached = PLUGIN_LIST_CACHE.get(cacheKey)
213
+ if (cached) return cached
214
+
215
+ const result = computePluginList(root, declared, options)
216
+ PLUGIN_LIST_CACHE.set(cacheKey, result)
217
+ return result
218
+ }
219
+
220
+ /**
221
+ * Обчислення `resolvePluginList` без кешу (винесено окремо, щоб кеш-обгортка лишалась тонкою).
222
+ * @param {string} root абсолютний корінь репозиторію (вже пройшов через `resolve()`)
223
+ * @param {unknown} declared сире значення `config.plugins`
224
+ * @param {{ quiet?: boolean }} options прокинуті опції виклику
225
+ * @returns {string[]} npm-імена плагінів
226
+ */
227
+ function computePluginList(root, declared, options) {
228
+ if (!Array.isArray(declared)) return detectPluginsFromRepo(root)
229
+
230
+ const names = declared.filter(p => typeof p === 'string' && p.trim() !== '')
231
+ if (names.length === 0) return names
232
+
233
+ const declaredCategories = new Set(names.map(pkg => pluginCategory(pkg)))
234
+ // Хоч один сторонній пакет у списку — не вгадуємо намір, повертаємо як є.
235
+ if (declaredCategories.has(null)) return names
236
+ // Усі відомі категорії вже покриті явним списком — не марнуємо файлові сигнали.
237
+ if ([...ALL_KNOWN_CATEGORIES].every(c => declaredCategories.has(c))) return names
238
+
239
+ const missing = detectPluginsFromRepo(root).filter(pkg => !declaredCategories.has(pluginCategory(pkg)))
240
+ if (missing.length > 0 && options.quiet !== true) {
241
+ const word = missing.length > 1 ? 'плагіни' : 'плагін'
242
+ console.warn(
243
+ `⚠️ Додано автодетектом ${word} ${missing.join(', ')} — категорія не задекларована явно в .n-rules.json\n`
244
+ )
166
245
  }
167
- return detectPluginsFromRepo(projectRoot)
246
+ return [...names, ...missing]
168
247
  }
169
248
 
170
249
  /**
@@ -240,7 +319,7 @@ function readPluginManifest(packageRoot) {
240
319
  */
241
320
  export function resolvePlugins(projectRoot, config, options = {}) {
242
321
  const root = resolve(projectRoot)
243
- const names = resolvePluginList(root, config)
322
+ const names = resolvePluginList(root, config, { quiet: options.quiet })
244
323
  const cacheKey = `${root}${names.join(',')}${options.allowInstall !== false}`
245
324
  const cached = RESOLVE_CACHE.get(cacheKey)
246
325
  if (cached) return cached
@@ -341,4 +420,5 @@ export function getHandlers(projectRoot, config, point) {
341
420
  /** Скидає кеш резолву (для тестів). */
342
421
  export function clearPluginResolveCache() {
343
422
  RESOLVE_CACHE.clear()
423
+ PLUGIN_LIST_CACHE.clear()
344
424
  }