@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.
@@ -23,10 +23,17 @@
23
23
  * `{ "capabilities": ["ci:github"], "contributes": { "rules": true, "handlers": { "<point>": "./mod.mjs" } } }`.
24
24
  * `capabilities` живлять гейт концернів (`concern.json` → `requires.capability`);
25
25
  * `handlers` — іменовані extension-points правил ядра (v1: лише API, споживачі — v2).
26
+ *
27
+ * Сумісність plugin API (Фаза 0, spec 2026-07-27-universal-plugin-slots-lang-php-extraction.md
28
+ * §10): маніфест може декларувати число `requiresPluginApi`. Якщо воно більше за
29
+ * `PLUGIN_API_VERSION` цього core — плагін несумісний і пропускається у `resolvePlugins()` із
30
+ * warning (окрім `quiet:true`, де пропуск тихий). Відсутнє або нечислове поле — сумісний, як і
31
+ * всі чинні на сьогодні маніфести (жоден з них поля ще не декларує).
26
32
  */
27
33
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
28
34
  import { spawnSync } from 'node:child_process'
29
35
  import { join, resolve } from 'node:path'
36
+ import { PLUGIN_API_VERSION } from './plugin-api.mjs'
30
37
 
31
38
  /** Відомі CI-плагіни для автовизначення: сигнал у дереві репо → npm-пакет. */
32
39
  export const KNOWN_CI_PLUGINS = Object.freeze({
@@ -246,19 +253,41 @@ function computePluginList(root, declared, options) {
246
253
  return [...names, ...missing]
247
254
  }
248
255
 
256
+ /**
257
+ * Сумісний semver-range для first-party плагінів: обмежує автоматичну інсталяцію (`ensurePluginInstalled`)
258
+ * поточною core-сумісною лінією, щоб майбутній несумісний major/minor плагіна не встановився
259
+ * мовчки поверх старого core (Фаза 0, spec 2026-07-27-universal-plugin-slots-lang-php-extraction.md
260
+ * §10). Для `0.x`-пакетів — caret на поточний minor (`^0.22`, а не голий `^0`, який під caret-
261
+ * семантикою розгортається у весь діапазон `0.x`); для `>=1` — caret на поточний major (`^1`).
262
+ * Невідомий (сторонній, не з цієї таблиці) пакет інсталюється без обмеження версії — як і раніше.
263
+ */
264
+ export const KNOWN_PLUGIN_RANGES = Object.freeze({
265
+ '@7n/rules-ci-github': '^1',
266
+ '@7n/rules-ci-azure': '^1',
267
+ '@7n/rules-lang-js': '^0.22',
268
+ '@7n/rules-lang-python': '^0.10',
269
+ '@7n/rules-lang-rust': '^0.13'
270
+ })
271
+
249
272
  /**
250
273
  * Гарантує, що плагін встановлений: якщо `node_modules/<pkg>` нема — `bun add -d <pkg>`
251
- * (дописує devDependency і ставить). Фейл warning + false, без винятку.
274
+ * (дописує devDependency і ставить). Для first-party пакетів з `KNOWN_PLUGIN_RANGES` версія
275
+ * обмежується сумісним range (`<pkg>@^<major>` або `@^<major>.<minor>` для `0.x`); сторонні
276
+ * пакети встановлюються без обмеження, bun сам резолвить latest. Фейл — warning + false, без
277
+ * винятку.
252
278
  * @param {string} projectRoot корінь репозиторію
253
279
  * @param {string} packageName npm-ім'я плагіна
280
+ * @param {typeof import('node:child_process').spawnSync} [spawnFn] інжект для тестів (типово — реальний `spawnSync`)
254
281
  * @returns {boolean} true — пакет доступний у node_modules після виклику
255
282
  */
256
- export function ensurePluginInstalled(projectRoot, packageName) {
283
+ export function ensurePluginInstalled(projectRoot, packageName, spawnFn = spawnSync) {
257
284
  const installed = join(projectRoot, 'node_modules', packageName, 'package.json')
258
285
  if (existsSync(installed)) return true
259
286
  if (!existsSync(join(projectRoot, 'package.json'))) return false
260
287
 
261
- const r = spawnSync('bun', ['add', '-d', packageName], { cwd: projectRoot, encoding: 'utf8', shell: false })
288
+ const range = KNOWN_PLUGIN_RANGES[packageName]
289
+ const spec = range ? `${packageName}@${range}` : packageName
290
+ const r = spawnFn('bun', ['add', '-d', spec], { cwd: projectRoot, encoding: 'utf8', shell: false })
262
291
  if (r.error || r.status !== 0) {
263
292
  const reason = r.error ? r.error.message : `bun add exit ${r.status}`
264
293
  console.warn(`⚠️ Плагін ${packageName} не встановився (${reason}) — пропускаю\n`)
@@ -272,22 +301,30 @@ export function ensurePluginInstalled(projectRoot, packageName) {
272
301
  * @property {string} name npm-ім'я пакета (`@7n/rules` для ядра)
273
302
  * @property {string} packageRoot абсолютний корінь пакета
274
303
  * @property {string} rulesDir абсолютний шлях до `rules/` пакета
275
- * @property {{ capabilities: string[], contributes: { rules?: boolean, handlers?: Record<string, string>, docFilesExtensions?: Record<string, string> } }} manifest нормалізований блок `n-rules` з package.json плагіна
304
+ * @property {{ capabilities: string[], requiresPluginApi: number | null, contributes: { rules?: boolean, handlers?: Record<string, string>, docFilesExtensions?: Record<string, string> } }} manifest нормалізований блок `n-rules` з package.json плагіна
276
305
  */
277
306
 
278
307
  /**
279
308
  * Маніфест плагіна з блоку `"n-rules"` його package.json (з дефолтами).
309
+ * `requiresPluginApi` — необов'язкове число; нечислове/відсутнє значення нормалізується у
310
+ * `null` (сумісний за замовчуванням — сумісність перевіряє `resolvePlugins()`).
280
311
  * @param {string} packageRoot корінь пакета
281
312
  * @returns {ResolvedPlugin['manifest']} нормалізований маніфест
282
313
  */
283
314
  function readPluginManifest(packageRoot) {
284
315
  /** @type {ResolvedPlugin['manifest']} */
285
- const fallback = { capabilities: [], contributes: { rules: true, handlers: {}, docFilesExtensions: {} } }
316
+ const fallback = {
317
+ capabilities: [],
318
+ requiresPluginApi: null,
319
+ contributes: { rules: true, handlers: {}, docFilesExtensions: {} }
320
+ }
286
321
  try {
287
322
  const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf8'))
288
323
  const raw = pkg?.['n-rules']
289
324
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return fallback
290
325
  const capabilities = Array.isArray(raw.capabilities) ? raw.capabilities.filter(c => typeof c === 'string') : []
326
+ const requiresPluginApi =
327
+ typeof raw.requiresPluginApi === 'number' && Number.isFinite(raw.requiresPluginApi) ? raw.requiresPluginApi : null
291
328
  const contributes = raw.contributes && typeof raw.contributes === 'object' ? raw.contributes : {}
292
329
  const handlers =
293
330
  contributes.handlers && typeof contributes.handlers === 'object' && !Array.isArray(contributes.handlers)
@@ -303,12 +340,38 @@ function readPluginManifest(packageRoot) {
303
340
  Object.entries(rawDocFiles.extensions).filter(([k, v]) => k.startsWith('.') && typeof v === 'string')
304
341
  )
305
342
  : {}
306
- return { capabilities, contributes: { rules: contributes.rules !== false, handlers, docFilesExtensions } }
343
+ return {
344
+ capabilities,
345
+ requiresPluginApi,
346
+ contributes: { rules: contributes.rules !== false, handlers, docFilesExtensions }
347
+ }
307
348
  } catch {
308
349
  return fallback
309
350
  }
310
351
  }
311
352
 
353
+ /**
354
+ * Чи декларує маніфест plugin API, несумісний із цією core-лінією (Фаза 0 передумова повної
355
+ * slots-міграції: `requiresPluginApi > PLUGIN_API_VERSION`). Друкує warning (окрім
356
+ * `quiet:true`) — виносить умову й побічний ефект з `resolvePlugins()`, щоб не роздувати її
357
+ * cognitive complexity.
358
+ * @param {string} name npm-ім'я плагіна (для повідомлення)
359
+ * @param {ResolvedPlugin['manifest']} manifest нормалізований маніфест плагіна
360
+ * @param {boolean} quiet без warning-у
361
+ * @returns {boolean} true — плагін несумісний, пропускаємо
362
+ */
363
+ function isIncompatiblePluginApi(name, manifest, quiet) {
364
+ if (manifest.requiresPluginApi === null || manifest.requiresPluginApi <= PLUGIN_API_VERSION) return false
365
+ // Плагін декларує plugin API, несумісний із цією core-лінією — пропускаємо, а не
366
+ // завантажуємо його як rules-only з мовчазною втратою handlers/doc-files/fragments.
367
+ if (!quiet) {
368
+ console.warn(
369
+ `⚠️ Плагін ${name} потребує plugin API v${manifest.requiresPluginApi}, ця core-лінія підтримує v${PLUGIN_API_VERSION} — пропускаю, онови @7n/rules\n`
370
+ )
371
+ }
372
+ return true
373
+ }
374
+
312
375
  /**
313
376
  * Повний резолв плагінів проєкту (з кешем на процес).
314
377
  * @param {string} projectRoot корінь репозиторію
@@ -338,6 +401,7 @@ export function resolvePlugins(projectRoot, config, options = {}) {
338
401
  continue
339
402
  }
340
403
  const manifest = readPluginManifest(packageRoot)
404
+ if (isIncompatiblePluginApi(name, manifest, options.quiet === true)) continue
341
405
  const rulesDir = join(packageRoot, 'rules')
342
406
  if (manifest.contributes.rules && !existsSync(rulesDir)) {
343
407
  // Плагін ДЕКЛАРУЄ правила (rules !== false), але каталогу нема — битий пакет.
@@ -13,9 +13,10 @@ version: '1.0'
13
13
  у теці `docs/` **поряд із самим файлом** (`<dir>/docs/<stem>.md`). Це **обовʼязковий крок кожної
14
14
  задачі** — як `lint`: після зміни коду його дока має бути перегенерована.
15
15
 
16
- Застарілість визначається **детерміновано за CRC**: кожна дока несе у frontmatter контрольну
17
- суму байтів джерела на момент генерації. Дока **застаріла**, якщо її немає або
18
- `crc(поточне джерело) crc у frontmatter`.
16
+ Застарілість визначається **детерміновано за CRC evidence**: кожна дока несе у frontmatter
17
+ контрольну суму source + повʼязаних test/spec-файлів на момент генерації. Без повʼязаних
18
+ тестів це звичайний CRC source. Дока **застаріла**, якщо її немає або поточний evidence CRC
19
+ не збігається з `crc` у frontmatter.
19
20
 
20
21
  ```markdown
21
22
  ---
@@ -60,6 +61,18 @@ npx @7n/rules lint doc-files
60
61
  (`stale`) → генерує локальною моделлю → пише доку зі **свіжим CRC** (і degraded-маркером,
61
62
  якщо не дотягнула) → друкує прогрес і підсумок.
62
63
 
64
+ Повʼязані тести визначаються за relative reference, який резолвиться у source (`import`,
65
+ `require`, dynamic import, `vi.mock` тощо), плюс naming/layout evidence (`foo.test` → `foo`
66
+ або `module/tests/*` → module `main`/`index`). Shared test helpers, які тест лише імпортує,
67
+ не стають evidence. JS детерміновано рендерить один компактний рядок на test-файл: до двох
68
+ груп, пʼять дослівних прикладів і точний лічильник решти; test-код і сценарії не потрапляють до
69
+ моделі. Зміна такого тесту робить source-доку stale.
70
+
71
+ Авторські коментарі теж є першоджерелом: для JSDoc, rustdoc і Python docstring-ів JS дослівно
72
+ збирає «Огляд» і «Публічний API». Детальний наратив дає `comment-only` (0 LLM); короткий
73
+ pointer або складний flow — `comment+behavior`, де LLM дописує тільки «Поведінку», а judge
74
+ перевіряє лише її. За неповних коментарів лишається звичайний `fallback`.
75
+
63
76
  ### Крок 2: Підтвердження
64
77
 
65
78
  Дочекайся підсумку `✓ OK: <N> ⚠ degraded: <D> ✗ Err: <E>`. Якщо є помилки — перелічи
@@ -80,7 +93,9 @@ preflight). Degraded — не помилка: дока існує, CRC свіж
80
93
  ## Нотатки
81
94
 
82
95
  - Не комітити автоматично — користувач вирішує, коли комітити згенеровану доку.
83
- - Scanner ігнорує `node_modules`, `dist`, `.git`, `__pycache__`, `coverage`, `.cursor`, `.claude`,
84
- усі теки `docs/`, а також `*.test.*` / `*.spec.*` / `*.d.ts`. Кореневий repo `docs/` —
85
- system-wide only: file-level docs туди не пишуться. Список glob-ів — `docgen-ignore.mjs`.
96
+ - Scanner не створює окремих док для `*.test.*` / `*.spec.*`, але використовує повʼязані
97
+ тести як evidence для source-доки. Він ігнорує `node_modules`, `dist`, `.git`,
98
+ `__pycache__`, `coverage`, `.cursor`, `.claude`, усі теки `docs/` і `*.d.ts`.
99
+ Кореневий repo `docs/` — system-wide only: file-level docs туди не пишуться. Список
100
+ glob-ів — `docgen-ignore.mjs`.
86
101
  - Агрегуюча документація (module-summary, доменні доки) — окремий скіл `doc-aggregate`, за запитом.